MyBatis-Plus
技术栈概述
本章节用于说明 Spring Boot 3 与 MyBatis-Plus 组合使用时的技术定位、版本边界、迁移影响和适用场景。对于新项目,应优先按 Spring Boot 3、JDK 17+、Jakarta EE、MyBatis-Plus Spring Boot 3 Starter 的组合进行选型;对于旧项目升级,则需要重点关注依赖兼容、包名迁移、插件配置和数据访问层改造成本。Spring Boot 3.x 的最低运行基线是 Java 17,不同 3.x 小版本对更高 JDK 的兼容范围会有所变化。(Home)
Spring Boot 3 特性变化
Spring Boot 3 是一次具有明显断代特征的主版本升级,核心变化集中在 JDK 基线、Spring Framework 6、Jakarta EE 包名迁移、可观测性能力增强、AOT 与 Native Image 支持等方面。对于 MyBatis-Plus 项目而言,影响最大的是运行环境和依赖生态,而不是单表 CRUD 写法本身。Spring Boot 3.0.x 要求 Java 17,Spring Boot 3.2.x 仍以 Java 17 为最低要求,并依赖 Spring Framework 6.1.x 或更高版本。(Home)
在实际开发中,Spring Boot 3 带来的主要变化可以归纳为以下几类。
第一,JDK 版本需要提升到 17 或以上。项目可以使用 record、var、增强 switch、文本块等新语法,但在企业级后端项目中,应优先保证可读性和团队规范一致,不建议为了语法新特性过度改造业务代码。
第二,底层 Spring 版本升级到 Spring Framework 6。常见影响包括依赖版本整体抬升、第三方 Starter 需要选择支持 Spring Boot 3 的版本、旧版库可能出现类不存在或自动配置失效问题。
第三,Java EE 相关 API 迁移到 Jakarta EE。原来的 javax.servlet、javax.validation、javax.persistence 等包名,在 Spring Boot 3 生态中通常需要切换到 jakarta.servlet、jakarta.validation、jakarta.persistence。如果项目中使用了参数校验、Servlet 过滤器、JPA 注解、文件上传、拦截器等能力,需要重点排查包名和依赖版本。
第四,内置 Web 容器版本升级。Spring Boot 3.2.x 默认支持 Tomcat 10.1、Jetty 12、Undertow 2.3 等 Servlet 6.0 相关运行环境,这意味着旧 Servlet 依赖、旧过滤器组件和部分第三方 Web 组件可能需要同步升级。(docs.enterprise.spring.io)
第五,可观测性能力更强。Spring Boot 3 对 Actuator、Micrometer、Tracing 等能力的集成更完整,适合在生产环境中统一接入接口耗时、SQL 耗时、异常指标、数据库连接池指标和链路追踪。对于 MyBatis-Plus 项目,建议结合 SQL 日志、慢 SQL 监控、连接池监控一起规划。
MyBatis-Plus 功能定位
MyBatis-Plus 是 MyBatis 的增强工具,定位是“只增强,不改变”,核心目标是减少重复 CRUD 代码,提高单表数据访问层的开发效率。它不替代 MyBatis 的底层能力,也不强制改变 MyBatis 的 XML、自定义 SQL、ResultMap、TypeHandler 等使用方式。官方文档也明确将其描述为基于 MyBatis 的增强工具,用于简化开发、提高效率。(mybatis.plus)
在 Spring Boot 3 项目中,MyBatis-Plus 通常承担以下职责。
一是提供通用 Mapper 能力。开发者定义实体类和 Mapper 接口后,即可通过 BaseMapper 获得新增、删除、修改、查询等基础能力,减少重复 SQL 和重复 Mapper 方法。
二是提供通用 Service 能力。通过 IService 和 ServiceImpl,可以统一封装 save、saveBatch、remove、update、getById、list、page 等常见业务入口,使 Service 层代码更聚焦业务规则。MyBatis-Plus 官方 CRUD 文档也将通用 Service 与通用 Mapper 作为核心能力进行说明。(mybatis.plus)
三是提供条件构造器。QueryWrapper、LambdaQueryWrapper、UpdateWrapper、LambdaUpdateWrapper 可以用 Java 代码动态拼接查询和更新条件,适合处理后台管理系统中常见的多条件筛选、状态查询、时间范围查询、模糊查询、排序查询等场景。
四是提供常用插件能力。分页、逻辑删除、乐观锁、多租户、动态表名、数据权限等能力可以通过插件或拦截器扩展实现。对于业务系统而言,这些能力通常比手写重复 SQL 更容易统一规范和集中治理。
五是保留 MyBatis 原生扩展能力。复杂报表、多表 JOIN、窗口函数、复杂动态 SQL、特殊 ResultMap 映射等场景,仍然可以继续使用 XML 或注解 SQL,不需要强行用 Wrapper 表达所有查询逻辑。
在定位上,MyBatis-Plus 更适合承担“标准化单表 CRUD + 常见增强能力 + 局部复杂 SQL 保留 MyBatis 原生写法”的职责。它不适合被当成完整 ORM 框架,也不建议用它屏蔽所有 SQL 设计细节。
MyBatis 与 MyBatis-Plus 的关系
MyBatis 是持久层框架,负责 SQL 映射、参数绑定、结果映射、动态 SQL、一级缓存、插件机制等基础能力。MyBatis-Plus 是构建在 MyBatis 之上的增强层,底层仍然依赖 MyBatis 的执行机制。理解二者关系时,可以简单归纳为:MyBatis 解决“SQL 如何执行”,MyBatis-Plus 解决“常见 SQL 不要重复写”。MyBatis-Plus 官方也强调其是在 MyBatis 基础上增强,而不是改变 MyBatis。(mybatis.plus)
在项目分层中,二者的关系通常表现为以下方式。
MyBatis 负责底层 SQL 能力,例如 XML 映射文件、自定义 Mapper 方法、动态 SQL、resultMap、typeHandler、插件拦截等。只要项目存在复杂查询、复杂映射或数据库特性 SQL,就仍然需要掌握 MyBatis 原生能力。
MyBatis-Plus 负责通用开发效率,例如单表 CRUD、批量保存、分页查询、条件构造、逻辑删除、乐观锁、自动填充等。它可以显著减少 Mapper 层和 Service 层的模板代码,但不应取代对 SQL、索引和事务的理解。
在实际编码规范中,建议遵循以下边界。
简单单表操作优先使用 MyBatis-Plus,例如根据 ID 查询、状态更新、分页列表、条件查询、逻辑删除等。
复杂 SQL 优先使用 MyBatis XML,例如多表 JOIN、统计报表、复杂分组、窗口函数、递归查询、特殊数据库函数等。
动态条件较多但仍属于单表查询时,优先使用 LambdaQueryWrapper,避免硬编码数据库字段名,降低字段重构时的维护成本。
不要为了“全项目都用 Wrapper”而牺牲 SQL 可读性。Wrapper 适合表达常规条件,不适合承载复杂报表逻辑。
不要在 Controller 层直接调用 Mapper。推荐 Controller 调 Service,Service 处理业务规则和事务,Mapper 只负责数据库访问。
JDK 版本与 Jakarta 迁移影响
Spring Boot 3 项目的首要前置条件是 JDK 17 或以上。对于新项目,建议直接选择 JDK 17 或 JDK 21 作为长期维护版本;对于存量项目升级,应先完成 JDK 升级、依赖升级和编译问题处理,再逐步迁移业务代码。Spring Boot 官方文档明确列出了 3.x 对 Java 版本和构建工具版本的要求,因此项目初始化时应优先确认 JDK、Maven、Gradle、IDE、CI/CD 镜像是否一致。(Home)
Jakarta 迁移是 Spring Boot 3 升级中最容易被低估的问题。其本质是 Java EE 相关规范包名从 javax.* 迁移到 jakarta.*。例如参数校验通常从 javax.validation.* 切换为 jakarta.validation.*,Servlet 相关类通常从 javax.servlet.* 切换为 jakarta.servlet.*。如果项目中存在自定义过滤器、拦截器、全局异常处理、参数校验、自定义注解校验、文件上传、第三方 Web 组件,需要逐项检查。
对 MyBatis-Plus 本身而言,常规 Entity、Mapper、Service、Wrapper 写法受 Jakarta 迁移影响较小,因为 MyBatis-Plus 主要处理数据库映射和 SQL 执行增强。但在完整 Spring Boot 3 项目中,以下位置仍然容易出现兼容问题。
第一,校验注解需要迁移。例如 @NotNull、@NotBlank、@Size 等注解所在包名需要使用 Jakarta Validation 对应包。
第二,Servlet API 需要迁移。例如自定义 Filter、HttpServletRequest、HttpServletResponse、文件上传处理等相关代码,需要使用 Jakarta Servlet 对应包。
第三,第三方依赖需要选择 Spring Boot 3 兼容版本。例如 MyBatis-Plus 在 Spring Boot 3 场景下应选择 mybatis-plus-spring-boot3-starter,官方安装文档已经对 Spring Boot 2、Spring Boot 3 和 Spring Boot 4 场景做了区分。(MyBatis-Plus)
第四,旧版 Swagger、Knife4j、SpringFox 等接口文档依赖可能不兼容 Spring Boot 3。新项目更建议选择 SpringDoc 或支持 Spring Boot 3 的 Knife4j 版本。
第五,构建和运行环境需要统一。开发机、测试环境、Docker 镜像、CI 编译环境、生产运行环境都应使用一致的 JDK 主版本,避免本地能运行、流水线或容器启动失败。
常见项目适用场景
Spring Boot 3 与 MyBatis-Plus 的组合适合大多数以关系型数据库为核心的业务系统,尤其适合后台管理系统、中台系统、企业内部系统、权限系统、订单库存类系统、低代码配置型系统和数据维护型系统。其优势在于开发效率高、代码结构清晰、单表 CRUD 成本低、复杂 SQL 又可以回退到 MyBatis XML。
常见适用场景包括以下几类。
第一,后台管理系统。用户、角色、菜单、部门、岗位、字典、参数配置、操作日志、登录日志等模块通常包含大量标准增删改查、分页查询、状态启停、批量删除和详情查询,非常适合使用 MyBatis-Plus 快速落地。
第二,业务中台系统。订单、库存、商品、客户、合同、审批、结算等模块通常既有大量单表维护,又有部分复杂查询。单表维护可以使用 MyBatis-Plus,复杂统计和报表可以使用 MyBatis XML。
第三,权限和数据隔离系统。MyBatis-Plus 提供多租户、数据权限、逻辑删除等扩展思路,适合在统一技术规范下处理租户隔离、部门权限、角色数据范围、软删除等通用能力。
第四,快速原型和内部工具。对于字段变化频繁、需求迭代快、接口数量多的内部管理工具,MyBatis-Plus 可以减少重复 Mapper 和 Service 代码,使团队更快完成基础功能。
第五,传统 MyBatis 项目升级。已有 MyBatis 项目可以渐进式引入 MyBatis-Plus。原有 XML、自定义 SQL、Mapper 逻辑可以保留,新模块或标准 CRUD 模块逐步使用 MyBatis-Plus,不需要一次性重写整个持久层。
但以下场景不建议过度依赖 MyBatis-Plus。
如果项目核心是极复杂报表、强 SQL 调优、跨库查询、复杂 ETL 或大量数据库特性函数,仍应以 MyBatis XML、手写 SQL 和数据库优化为主。
如果项目领域模型复杂、对象关系映射需求重、聚合根和关联关系较复杂,可以评估 JPA、jOOQ 或更贴合领域建模的方案。
如果项目是超高并发写入、海量数据分析或 OLAP 查询场景,应优先关注数据库架构、分库分表、缓存、消息队列、批处理和查询引擎设计,MyBatis-Plus 只能解决持久层编码效率,不能替代系统架构设计。
项目初始化
项目初始化阶段需要先确定构建工具、JDK 版本、包结构、模块边界和基础依赖。Spring Boot 3 项目建议以 JDK 17+ 作为基础运行环境,Maven、Gradle、IDE、CI/CD 镜像和生产运行环境应保持一致,避免本地能编译、流水线或容器环境失败。Spring Boot 3.x 官方系统要求中,Java 17 是核心基线,构建工具也需要使用明确支持的 Maven 或 Gradle 版本。(Spring Enterprise 文档)
Maven 项目结构
Maven 是 Spring Boot 后端项目中最常见的构建工具,适合企业内部系统、后台管理系统、中台服务和标准微服务项目。单模块项目结构简单,适合功能边界清晰、规模较小或前期快速开发的业务系统。
推荐的单模块 Maven 项目结构如下:
mybatis-plus-demo
├── pom.xml
├── README.md
├── src
│ ├── main
│ │ ├── java
│ │ │ └── io
│ │ │ └── github
│ │ │ └── atengk
│ │ │ ├── MybatisPlusDemoApplication.java
│ │ │ ├── common
│ │ │ │ ├── config
│ │ │ │ ├── constant
│ │ │ │ ├── enums
│ │ │ │ ├── exception
│ │ │ │ ├── result
│ │ │ │ └── util
│ │ │ └── system
│ │ │ └── user
│ │ │ ├── controller
│ │ │ ├── service
│ │ │ │ └── impl
│ │ │ ├── mapper
│ │ │ ├── entity
│ │ │ ├── dto
│ │ │ ├── vo
│ │ │ ├── query
│ │ │ └── convert
│ │ └── resources
│ │ ├── application.yml
│ │ ├── application-dev.yml
│ │ ├── application-prod.yml
│ │ └── mapper
│ │ └── system
│ │ └── UserMapper.xml
│ └── test
│ └── java
│ └── io
│ └── github
│ └── atengk
└── target2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
这种结构的核心原则是:公共能力放在 common 包,业务模块按领域划分,例如 system.user、system.role、order.trade、file.attachment。每个业务模块内部再按 Controller、Service、Mapper、Entity、DTO、VO、Query、Convert 分层。
resources/mapper 用于存放 MyBatis XML 文件。即使项目主要使用 MyBatis-Plus 的 BaseMapper,也建议预留 XML 目录,因为复杂查询、多表 JOIN、统计报表和特殊 ResultMap 仍然更适合写在 XML 中。
Gradle 项目结构
Gradle 适合对构建速度、插件扩展、任务编排和多模块构建有较高要求的项目。对于 Spring Boot 3 + MyBatis-Plus 项目,Gradle 项目结构与 Maven 基本一致,区别主要体现在构建脚本文件。
推荐的单模块 Gradle 项目结构如下:
mybatis-plus-demo
├── build.gradle
├── settings.gradle
├── gradlew
├── gradlew.bat
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── src
│ ├── main
│ │ ├── java
│ │ │ └── io/github/atengk
│ │ └── resources
│ │ ├── application.yml
│ │ └── mapper
│ └── test
│ └── java
│ └── io/github/atengk
└── README.md2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Gradle 项目中建议使用 Wrapper,即 gradlew 和 gradle-wrapper.properties,保证团队成员和 CI/CD 使用一致的 Gradle 版本。Spring Boot 3 对 Gradle 版本有明确支持范围,项目不要随意使用过旧 Gradle,否则容易出现插件不兼容、依赖解析失败或编译参数不生效等问题。(Spring Enterprise 文档)
Gradle 依赖示例可以写成如下形式。
这段配置用于初始化 Spring Boot 3、MyBatis-Plus、Lombok、Hutool、MapStruct 和接口文档依赖。
plugins {
// Spring Boot 构建插件
id 'org.springframework.boot' version '3.3.6'
// Spring 依赖管理插件
id 'io.spring.dependency-management' version '1.1.6'
// Java 项目插件
id 'java'
}
group = 'io.github.atengk'
version = '1.0.0'
java {
// Spring Boot 3 建议使用 JDK 17 或以上版本
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
// 使用 Maven 中央仓库
mavenCentral()
}
dependencies {
// Spring MVC Web 能力
implementation 'org.springframework.boot:spring-boot-starter-web'
// 参数校验,Spring Boot 3 使用 Jakarta Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
// MyBatis-Plus Spring Boot 3 Starter
implementation 'com.baomidou:mybatis-plus-spring-boot3-starter:3.5.16'
// MySQL 驱动,实际版本可交给 Spring Boot 依赖管理控制
runtimeOnly 'com.mysql:mysql-connector-j'
// Hutool 工具类库
implementation 'cn.hutool:hutool-all:5.8.44'
// MapStruct 对象转换
implementation 'org.mapstruct:mapstruct:1.6.3'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'
// Lombok 简化实体类、DTO、VO 代码
compileOnly 'org.projectlombok:lombok:1.18.44'
annotationProcessor 'org.projectlombok:lombok:1.18.44'
// Lombok 与 MapStruct 配合时建议加入,避免生成顺序导致映射异常
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
// SpringDoc OpenAPI 文档
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17'
// 测试依赖
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
Gradle 项目中要特别注意 annotationProcessor 配置。Lombok 和 MapStruct 都依赖编译期注解处理器,如果只声明 implementation,IDE 可能能识别一部分代码,但命令行编译或 CI 编译可能失败。
多模块项目结构
当项目逐渐变大,或者需要拆分公共能力、业务模块、启动模块时,可以使用多模块结构。多模块项目适合后台管理系统、微服务基础工程、业务中台和长期维护项目。
推荐的 Maven 多模块结构如下:
ateng-platform
├── pom.xml
├── ateng-common
│ └── pom.xml
├── ateng-framework
│ └── pom.xml
├── ateng-module-system
│ └── pom.xml
├── ateng-module-order
│ └── pom.xml
├── ateng-module-file
│ └── pom.xml
└── ateng-admin
└── pom.xml2
3
4
5
6
7
8
9
10
11
12
13
14
各模块职责建议如下:
| 模块 | 职责 |
|---|---|
ateng-common | 通用工具、常量、枚举、异常、响应对象、基础 DTO、基础 VO |
ateng-framework | Spring 配置、MyBatis-Plus 配置、Redis 配置、安全配置、全局异常处理 |
ateng-module-system | 用户、角色、菜单、部门、字典、参数配置等系统模块 |
ateng-module-order | 订单、库存、商品、交易等业务模块 |
ateng-module-file | 文件上传、附件绑定、文件记录、预览地址等文件模块 |
ateng-admin | 启动模块,包含启动类、环境配置、模块依赖聚合 |
多模块项目中,启动类一般只放在 ateng-admin 模块。业务模块不直接依赖启动模块,公共模块也不依赖业务模块,避免循环依赖。
推荐依赖方向如下:
ateng-admin
├── ateng-framework
├── ateng-module-system
├── ateng-module-order
└── ateng-module-file
ateng-framework
└── ateng-common
ateng-module-system
├── ateng-common
└── ateng-framework2
3
4
5
6
7
8
9
10
11
12
多模块拆分时不要过早拆得太细。对于普通管理系统,可以先按 common、framework、module-system、admin 拆分;等订单、文件、审批、报表等模块稳定后,再逐步独立为业务模块。
包结构规划
包结构决定了项目长期维护的清晰度。Spring Boot + MyBatis-Plus 项目建议按“领域模块优先,技术分层其次”的方式组织代码,即先按业务域分包,再在业务域下划分 Controller、Service、Mapper、Entity 等层。
推荐基础包名为:
io.github.atengk推荐包结构如下:
io.github.atengk
├── common
│ ├── constant
│ ├── enums
│ ├── exception
│ ├── result
│ ├── util
│ └── web
├── framework
│ ├── config
│ ├── handler
│ ├── interceptor
│ ├── mybatis
│ └── security
└── module
├── system
│ ├── user
│ │ ├── controller
│ │ ├── service
│ │ │ └── impl
│ │ ├── mapper
│ │ ├── entity
│ │ ├── dto
│ │ ├── vo
│ │ ├── query
│ │ └── convert
│ └── role
└── order
└── trade2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
常用包职责如下:
| 包名 | 职责 |
|---|---|
controller | 接收 HTTP 请求,处理参数校验,不写复杂业务逻辑 |
service | 定义业务接口,承载业务能力抽象 |
service.impl | 实现业务逻辑、事务控制、业务校验、调用 Mapper |
mapper | 数据库访问接口,继承 BaseMapper 或声明自定义 SQL 方法 |
entity | 数据库表映射对象,只描述表结构,不直接暴露给前端 |
dto | 新增、修改、导入等入参对象 |
query | 查询条件对象,尤其是分页和多条件筛选 |
vo | 接口返回对象 |
convert | DTO、Entity、VO 之间的转换接口,推荐使用 MapStruct |
common | 跨模块通用能力 |
framework | 框架级配置和基础设施能力 |
包结构规划时要避免两类问题:一是所有业务都堆在 controller、service、mapper 三个大包中,后期模块边界不清;二是过度抽象,在项目初期就拆出大量空包和空模块,增加维护成本。
Maven 依赖配置
Maven 项目建议使用 Spring Boot Parent 统一管理 Spring 生态依赖版本。对于 MyBatis-Plus、Hutool、MapStruct、Knife4j 等非 Spring Boot 管理的依赖,可以通过 <properties> 集中声明版本。
这段 pom.xml 用于初始化单模块 Spring Boot 3 + MyBatis-Plus 项目。
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Spring Boot 统一依赖管理 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.6</version>
<relativePath/>
</parent>
<groupId>io.github.atengk</groupId>
<artifactId>mybatis-plus-demo</artifactId>
<version>1.0.0</version>
<name>mybatis-plus-demo</name>
<description>Spring Boot 3 MyBatis-Plus 示例项目</description>
<properties>
<!-- Spring Boot 3 最低要求 JDK 17 -->
<java.version>17</java.version>
<!-- MyBatis-Plus Spring Boot 3 Starter 版本 -->
<mybatis-plus.version>3.5.16</mybatis-plus.version>
<!-- Hutool 工具类库版本 -->
<hutool.version>5.8.44</hutool.version>
<!-- MapStruct 对象转换版本 -->
<mapstruct.version>1.6.3</mapstruct.version>
<!-- Lombok 版本 -->
<lombok.version>1.18.44</lombok.version>
<!-- Lombok 与 MapStruct 注解处理顺序绑定 -->
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
<!-- SpringDoc OpenAPI 文档版本 -->
<springdoc.version>2.8.17</springdoc.version>
</properties>
<dependencies>
<!-- Spring MVC Web 能力 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 参数校验,Spring Boot 3 使用 Jakarta Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- MyBatis-Plus Spring Boot 3 集成 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MyBatis-Plus 分页、多租户等插件在 3.5.9+ 后部分能力需要按需引入相关支持模块 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL JDBC 驱动,运行时依赖即可 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Hutool 工具类库,常用于字符串、集合、日期、Bean、JSON 等工具处理 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!-- MapStruct API,用于 DTO、Entity、VO 转换 -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- Lombok 简化实体类、DTO、VO 代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<!-- SpringDoc OpenAPI 文档页面 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Spring Boot 测试能力 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Spring Boot 打包插件 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- Java 编译插件,配置 Lombok 与 MapStruct 注解处理器 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<!-- Lombok 注解处理器 -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<!-- MapStruct 注解处理器 -->
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<!-- Lombok 与 MapStruct 配合使用时推荐加入 -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
MyBatis-Plus 官方安装文档已经区分 Spring Boot 2、Spring Boot 3 和 Spring Boot 4 的 Starter;Spring Boot 3 项目应使用 mybatis-plus-spring-boot3-starter,不要继续使用 Spring Boot 2 场景下的 mybatis-plus-boot-starter。MyBatis-Plus 3.5.9+ 之后插件部分开始调整为可选依赖,分页、多租户等涉及 SQL 解析的能力要关注 mybatis-plus-jsqlparser 等模块是否需要显式引入。(MyBatis-Plus)
Spring Boot 3 兼容依赖选择
Spring Boot 3 的兼容依赖选择应优先遵循三个原则:使用 Spring Boot BOM 管理 Spring 生态依赖,第三方 Starter 必须明确支持 Spring Boot 3,涉及 javax.* 的旧依赖要升级到 Jakarta 兼容版本。
常见选择建议如下:
| 依赖类型 | 推荐选择 | 说明 |
|---|---|---|
| Web 框架 | spring-boot-starter-web | 基于 Spring MVC,适合常规 REST API |
| 参数校验 | spring-boot-starter-validation | Spring Boot 3 使用 Jakarta Validation |
| 持久层 | mybatis-plus-spring-boot3-starter | Spring Boot 3 项目专用 Starter |
| 数据库连接池 | HikariCP | Spring Boot 默认连接池,一般无需额外引入 |
| MySQL 驱动 | com.mysql:mysql-connector-j | 常规 MySQL 项目使用 |
| PostgreSQL 驱动 | org.postgresql:postgresql | PostgreSQL 项目使用 |
| 工具类 | cn.hutool:hutool-all | 项目中常用字符串、集合、日期、Bean 工具 |
| 对象转换 | org.mapstruct:mapstruct | 编译期生成转换代码,性能和类型安全较好 |
| 接口文档 | springdoc-openapi-starter-webmvc-ui | Spring Boot 3 项目常用 OpenAPI 文档方案 |
| 代码简化 | org.projectlombok:lombok | 编译期生成 Getter、Setter、构造方法等 |
不建议在 Spring Boot 3 项目中继续使用长期未维护或仅支持 Spring Boot 2 的 Starter。典型问题包括自动配置类路径变化、javax.* 包名无法解析、Servlet 版本不兼容、Swagger 文档无法启动、编译期注解处理器失效等。
MyBatis-Plus Starter 选择
MyBatis-Plus Starter 的选择要与 Spring Boot 主版本保持一致。对于 Spring Boot 3 项目,应选择:
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.16</version>
</dependency>2
3
4
5
官方安装文档中,Spring Boot 3 对应的依赖名称是 mybatis-plus-spring-boot3-starter;Maven Central 当前可检索到 3.5.16 版本。实际生产项目可以根据团队稳定性要求选择当前稳定版本,并在升级前完成分页、逻辑删除、乐观锁、自定义 SQL、代码生成器和插件功能的回归测试。(MyBatis-Plus)
选择规则如下:
| Spring Boot 版本 | 推荐 Starter |
|---|---|
| Spring Boot 2.x | mybatis-plus-boot-starter |
| Spring Boot 3.x | mybatis-plus-spring-boot3-starter |
| Spring Boot 4.x | mybatis-plus-spring-boot4-starter |
需要注意,MyBatis-Plus Starter 不等于所有扩展能力都已经完整引入。分页、多租户、动态表名、数据权限等能力依赖内部插件机制,其中部分能力涉及 SQL 解析。MyBatis-Plus 3.5.9+ 后插件部分有可选依赖调整,项目中如果配置分页插件或租户插件后启动异常,应优先检查相关 SQL 解析模块是否缺失。(MyBatis-Plus)
数据库驱动依赖
数据库驱动应根据实际数据库类型选择。Spring Boot 项目中数据库驱动通常使用 runtime 或 runtimeOnly 作用域,因为业务代码一般不直接编译依赖驱动类。
MySQL 项目使用:
<!-- MySQL JDBC 驱动,运行时加载 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>2
3
4
5
6
PostgreSQL 项目使用:
<!-- PostgreSQL JDBC 驱动,运行时加载 -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>2
3
4
5
6
Oracle、SQL Server、达梦、人大金仓等数据库也可以与 MyBatis-Plus 配合使用,但需要额外注意方言、分页插件 DbType、主键策略、字段类型映射、时间类型处理和驱动授权问题。
数据库驱动版本优先交给 Spring Boot 依赖管理控制,除非存在明确的数据库兼容性、安全漏洞修复或驱动 Bug 修复需求。Maven Central 当前可以检索到 MySQL Connector/J 9.x 和 PostgreSQL JDBC 42.7.x 等新版本,但生产项目不应只追求最新版本,应结合数据库服务端版本、连接池、SQL 回归测试和安全扫描结果综合选择。(Maven Central)
Lombok 依赖
Lombok 用于减少 Java 样板代码,例如 Getter、Setter、构造方法、Builder、日志字段等。MyBatis-Plus 项目中,Entity、DTO、VO、Query 对象通常会大量使用 Lombok。
Maven 依赖建议如下:
<!-- Lombok 只在编译期使用,不需要打包到运行环境 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.44</version>
<scope>provided</scope>
</dependency>2
3
4
5
6
7
Lombok 官方 Maven 配置建议使用 provided 作用域,并在需要时配置注解处理器;官方文档也说明,从 JDK 23 或模块化编译场景开始,显式配置 annotation processor 更重要。(Project Lombok)
使用 Lombok 时建议遵循以下规范:
| 场景 | 建议 |
|---|---|
| Entity | 可使用 @Getter、@Setter,谨慎使用 @Data |
| DTO / VO | 可使用 @Data 或 @Getter、@Setter |
| 日志 | 使用 @Slf4j |
| 构造方法 | 按需使用 @NoArgsConstructor、@AllArgsConstructor |
| Builder | 适合复杂对象构造,不建议所有类默认添加 |
| equals/hashCode | Entity 谨慎使用,避免与数据库主键生命周期冲突 |
Entity 类不建议无脑使用 @Data,因为它会生成 equals、hashCode 和 toString。在存在懒加载字段、大字段、敏感字段或复杂继承关系时,可能引起日志泄露、递归调用或集合行为异常。
Hutool 依赖
Hutool 是常用 Java 工具类库,适合在 Spring Boot 项目中处理字符串、集合、日期、对象转换、JSON、文件、加密、验证码、HTTP 请求等常见工具场景。Maven Central 当前可检索到 Hutool 5.8.44 版本。(Maven Repository)
Maven 依赖如下:
<!-- Hutool 工具类库,减少重复工具方法封装 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.44</version>
</dependency>2
3
4
5
6
在 MyBatis-Plus 项目中,Hutool 常见使用场景包括:
| 工具类 | 常见用途 |
|---|---|
StrUtil | 字符串判空、格式化、脱敏前置处理 |
CollUtil | 集合判空、集合转换、集合合并 |
ObjectUtil | 对象判空、默认值处理 |
BeanUtil | 简单对象属性复制 |
DateUtil | 日期转换、时间范围处理 |
IdUtil | 简单 ID、UUID、雪花 ID 工具 |
JSONUtil | JSON 字符串处理 |
DesensitizedUtil | 手机号、身份证、邮箱等脱敏 |
建议将 Hutool 用在通用工具处理和轻量对象转换中。对于核心 DTO、Entity、VO 转换,如果字段映射复杂、需要编译期检查或转换逻辑长期维护,建议优先使用 MapStruct;对于临时性、字段简单的内部转换,可以使用 Hutool BeanUtil。
MapStruct 依赖
MapStruct 是编译期对象映射工具,适合处理 DTO、Entity、VO、BO 之间的转换。它通过注解处理器在编译阶段生成实现类,避免运行时反射,类型安全和性能都比较好。MapStruct 官方文档将其定义为生成类型安全、性能良好且无依赖的 Bean 映射代码的注解处理器。(MapStruct)
Maven 依赖如下:
<!-- MapStruct API,用于声明对象转换接口 -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.3</version>
</dependency>2
3
4
5
6
编译插件中需要加入注解处理器:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<!-- Lombok 注解处理器 -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<!-- MapStruct 注解处理器 -->
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<!-- 解决 Lombok 与 MapStruct 配合时的属性识别问题 -->
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>${lombok-mapstruct-binding.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
MapStruct 适合以下转换场景:
| 场景 | 示例 |
|---|---|
| 新增对象转实体 | UserAddDTO -> UserEntity |
| 修改对象合并实体 | UserUpdateDTO -> UserEntity |
| 实体转详情 VO | UserEntity -> UserDetailVO |
| 实体列表转 VO 列表 | List<UserEntity> -> List<UserPageVO> |
| 多对象聚合转换 | UserEntity + DeptEntity -> UserDetailVO |
如果字段名称完全一致,MapStruct 配置很少;如果字段名称不一致,可以使用 @Mapping 显式声明。对于业务系统,建议每个模块维护自己的 convert 包,例如 system.user.convert.UserConvert,避免所有转换器堆在公共包中。
Knife4j 或 SpringDoc 依赖
Spring Boot 3 项目建议优先选择支持 OpenAPI 3 的接口文档方案。常见选择是 SpringDoc OpenAPI 或 Knife4j OpenAPI 3 Starter。SpringDoc 官方文档提供了 Spring Boot 3 场景下的 Starter,例如 springdoc-openapi-starter-webmvc-ui 或 springdoc-openapi-starter-webmvc-scalar;其文档页面默认可通过 Swagger UI 或 OpenAPI JSON 地址访问。(OpenAPI 3 Library for spring-boot)
SpringDoc Maven 依赖如下:
<!-- SpringDoc OpenAPI 文档,适合 Spring Boot 3 WebMVC 项目 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.17</version>
</dependency>2
3
4
5
6
Knife4j OpenAPI 3 Maven 依赖如下:
<!-- Knife4j OpenAPI 3 文档增强 UI,适合国内后台管理系统常见使用习惯 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.5.0</version>
</dependency>2
3
4
5
6
接口文档选型建议如下:
| 方案 | 适用场景 |
|---|---|
| SpringDoc | 更接近 OpenAPI 标准,适合通用 Spring Boot 3 项目 |
| Knife4j | UI 增强较多,适合国内后台管理系统和接口调试场景 |
| 两者同时使用 | 不建议,容易出现配置重复、资源冲突或页面混乱 |
Spring Boot 3 项目不要再使用旧的 SpringFox Swagger 2 方案。旧方案通常依赖 javax.*、老版本 Spring MVC 路径匹配机制或旧自动配置方式,升级成本和兼容风险较高。
接口文档初始化后,通常访问地址如下:
SpringDoc Swagger UI:
http://localhost:8080/swagger-ui/index.html
OpenAPI JSON:
http://localhost:8080/v3/api-docs
Knife4j UI:
http://localhost:8080/doc.html2
3
4
5
6
7
8
如果项目集成了 Spring Security、Sa-Token 或网关鉴权,需要放行 /swagger-ui/**、/v3/api-docs/**、/doc.html、/webjars/** 等文档资源路径,否则页面可能能打开但接口列表加载失败。
基础配置
基础配置用于统一项目的运行参数、数据源、MyBatis-Plus 行为、Mapper 扫描、XML 路径、命名映射、主键策略和日志输出。Spring Boot 支持通过 application.yml、环境变量、命令行参数、Profile 配置文件等方式外部化配置,适合在本地、测试、预发和生产环境之间复用同一套代码。Spring Boot 会加载 application.yml 以及 application-{profile}.yml 这类环境配置文件,Profile 配置可覆盖基础配置。(Spring Enterprise 文档)
application.yml 配置
application.yml 建议只放通用配置,环境差异配置放到 application-dev.yml、application-test.yml、application-prod.yml。例如应用名称、默认 Profile、MyBatis-Plus 基础行为、日志级别、接口文档开关等,可以放在主配置文件中;数据库地址、账号、密码、连接池大小、SQL 日志开关等,应按环境拆分。
文件位置:src/main/resources/application.yml
server:
# 服务端口,生产环境也可以通过环境变量 SERVER_PORT 覆盖
port: ${SERVER_PORT:8080}
spring:
application:
# 应用名称,建议和部署服务名保持一致
name: mybatis-plus-demo
profiles:
# 默认启用本地开发环境
active: ${SPRING_PROFILES_ACTIVE:dev}
jackson:
# 接口返回时间格式
date-format: yyyy-MM-dd HH:mm:ss
# 统一时区
time-zone: Asia/Shanghai
servlet:
multipart:
# 单个文件最大大小
max-file-size: 20MB
# 单次请求最大大小
max-request-size: 50MB
mybatis-plus:
# Mapper XML 文件路径,多模块项目建议使用 classpath*
mapper-locations: classpath*:/mapper/**/*.xml
# 实体类别名包,非必填;XML 中需要使用短类名时可配置
type-aliases-package: io.github.atengk.**.entity
configuration:
# 数据库下划线字段自动映射为 Java 驼峰属性
map-underscore-to-camel-case: true
# 关闭 MyBatis 二级缓存,业务系统通常优先使用 Redis 等外部缓存
cache-enabled: false
# 查询结果字段为 null 时,也调用 setter,便于 VO 转换或字段默认值处理
call-setters-on-nulls: true
global-config:
# 关闭 MyBatis-Plus 启动 Banner,减少启动日志噪声
banner: false
db-config:
# 全局主键策略,默认推荐 ASSIGN_ID
id-type: ASSIGN_ID
# 表名前缀,按项目实际情况启用;例如 sys_user、sys_role
# table-prefix: sys_
# 逻辑删除字段,后续逻辑删除章节会展开
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
logging:
level:
# 项目业务日志级别
io.github.atengk: info
# MyBatis Mapper 日志级别,开发环境可在 application-dev.yml 中改为 debug
io.github.atengk.**.mapper: info2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
MyBatis-Plus 官方配置文档说明,Spring Boot 项目可以直接通过 application.yml 或 application.properties 配置 MyBatis-Plus;其中 mapper-locations 默认值为 classpath*:/mapper/**/*.xml,多模块项目建议使用 classpath*: 扫描多个 Jar 包中的 XML 文件。(MyBatis-Plus)
数据源配置
数据源配置建议按环境拆分。本地开发环境可以直接写连接信息,生产环境应优先使用环境变量、配置中心或密钥管理系统,避免将数据库密码提交到代码仓库。Spring Boot 的配置外部化机制支持从 YAML、环境变量、命令行参数等位置读取配置,因此同一套应用代码可以在不同环境使用不同数据库配置。(Spring Enterprise 文档)
文件位置:src/main/resources/application-dev.yml
spring:
datasource:
# MySQL 8+ 驱动类
driver-class-name: com.mysql.cj.jdbc.Driver
# 本地开发数据库连接
url: jdbc:mysql://${MYSQL_HOST:127.0.0.1}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:mybatis_plus_demo}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
# 数据库账号
username: ${MYSQL_USERNAME:root}
# 数据库密码,本地可设置默认值,生产环境不要写死
password: ${MYSQL_PASSWORD:123456}
hikari:
# 连接池名称,便于监控和日志识别
pool-name: HikariPool-MyBatisPlusDemo
# 最小空闲连接数
minimum-idle: 5
# 最大连接数,需结合数据库 max_connections 和应用实例数评估
maximum-pool-size: 20
# 获取连接最大等待时间,单位毫秒
connection-timeout: 30000
# 空闲连接最大存活时间,单位毫秒
idle-timeout: 600000
# 连接最大生命周期,单位毫秒
max-lifetime: 1800000
logging:
level:
# 开发环境打开 Mapper debug 日志,便于观察 SQL 执行
io.github.atengk.**.mapper: debug2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
文件位置:src/main/resources/application-prod.yml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
# 生产环境必须通过环境变量、配置中心或密钥管理系统注入
url: ${MYSQL_URL}
username: ${MYSQL_USERNAME}
password: ${MYSQL_PASSWORD}
hikari:
pool-name: HikariPool-MyBatisPlusDemo
minimum-idle: ${MYSQL_MIN_IDLE:10}
maximum-pool-size: ${MYSQL_MAX_POOL_SIZE:50}
connection-timeout: ${MYSQL_CONNECTION_TIMEOUT:30000}
idle-timeout: ${MYSQL_IDLE_TIMEOUT:600000}
max-lifetime: ${MYSQL_MAX_LIFETIME:1800000}
logging:
level:
# 生产环境不要输出完整 SQL 参数,避免泄露敏感数据
io.github.atengk.**.mapper: info2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
生产环境中,数据库账号应遵循最小权限原则。普通业务服务通常只需要当前业务库的 DML 权限,不应直接授予全局管理员权限。涉及数据迁移、表结构变更、初始化脚本的权限,应交给 Flyway、Liquibase 或独立发布账号处理。
MyBatis-Plus 全局配置
MyBatis-Plus 全局配置主要用于控制数据库层面的默认行为,例如主键策略、逻辑删除字段、表名前缀、字段格式、启动 Banner 等。官方配置文档说明,MyBatis-Plus 的部分配置继承自 MyBatis 原生配置,另一部分是 MyBatis-Plus 自身扩展配置。(MyBatis-Plus)
推荐配置如下:
mybatis-plus:
global-config:
# 关闭 MyBatis-Plus Banner,启动日志更干净
banner: false
db-config:
# 主键策略,分布式或微服务项目推荐 ASSIGN_ID
id-type: ASSIGN_ID
# 逻辑删除字段
logic-delete-field: deleted
# 已删除值
logic-delete-value: 1
# 未删除值
logic-not-delete-value: 0
# 表名前缀,按需开启
# table-prefix: sys_2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
常用全局配置说明:
| 配置项 | 建议值 | 说明 |
|---|---|---|
banner | false | 关闭 MyBatis-Plus 启动 Banner |
db-config.id-type | ASSIGN_ID | 默认雪花 ID,适合分布式或微服务项目 |
db-config.logic-delete-field | deleted | 统一逻辑删除字段 |
db-config.logic-delete-value | 1 | 已删除标识 |
db-config.logic-not-delete-value | 0 | 未删除标识 |
db-config.table-prefix | 按需配置 | 表名前缀,如 sys_、biz_ |
如果项目不同表使用不同主键策略,可以在实体类字段上通过 @TableId(type = IdType.AUTO) 或 @TableId(type = IdType.ASSIGN_ID) 单独覆盖全局配置。全局配置适合制定默认规范,局部注解适合处理特殊表。
Mapper 扫描配置
Mapper 扫描用于让 Spring 容器识别 MyBatis Mapper 接口。Spring Boot + MyBatis-Plus 项目通常在启动类上添加 @MapperScan,统一扫描项目中的 Mapper 包。MyBatis-Plus 官方快速配置示例也使用 @MapperScan 扫描 Mapper 文件夹。(MyBatis-Plus)
文件位置:src/main/java/io/github/atengk/MybatisPlusDemoApplication.java
package io.github.atengk;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* MyBatis-Plus 示例项目启动类
*
* @author Ateng
* @since 2026-05-05
*/
@MapperScan("io.github.atengk.**.mapper")
@SpringBootApplication
public class MybatisPlusDemoApplication {
/**
* 启动 Spring Boot 应用
*
* @param args 启动参数
*/
public static void main(String[] args) {
SpringApplication.run(MybatisPlusDemoApplication.class, args);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@MapperScan("io.github.atengk.**.mapper") 适合按业务模块分散 Mapper 包的项目。如果团队希望扫描路径更明确,也可以写成多个精确包路径,例如:
@MapperScan({
"io.github.atengk.module.system.**.mapper",
"io.github.atengk.module.order.**.mapper",
"io.github.atengk.module.file.**.mapper"
})2
3
4
5
不建议在每个 Mapper 接口上都手动添加 @Mapper,因为模块多了以后容易遗漏。统一使用 @MapperScan 更适合工程化项目。
XML 映射文件路径配置
即使项目主要使用 MyBatis-Plus 的 BaseMapper,也建议保留 XML 映射文件目录。复杂查询、多表关联、统计报表、动态 SQL、ResultMap、TypeHandler 映射等场景,XML 仍然比 Wrapper 更清晰。
推荐目录结构如下:
src/main/resources
└── mapper
├── system
│ ├── UserMapper.xml
│ └── RoleMapper.xml
├── order
│ └── OrderMapper.xml
└── file
└── AttachmentMapper.xml2
3
4
5
6
7
8
9
对应配置如下:
mybatis-plus:
# 单模块和多模块都建议使用 classpath*
mapper-locations: classpath*:/mapper/**/*.xml2
3
MyBatis-Plus 配置文档说明,如果 Mapper 中存在自定义方法,需要配置 XML 文件位置;对于 Maven 多模块项目,扫描路径应以 classpath*: 开头,以加载多个 Jar 包中的 XML 文件。(MyBatis-Plus)
XML 文件示例:
文件位置:src/main/resources/mapper/system/UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- namespace 必须和 Mapper 接口全限定名一致 -->
<mapper namespace="io.github.atengk.module.system.user.mapper.UserMapper">
<!-- 用户分页查询字段,避免 SELECT * -->
<sql id="UserPageColumns">
id,
username,
nickname,
phone,
status,
create_time
</sql>
<!-- 自定义用户列表查询示例 -->
<select id="selectUserPageList" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
<include refid="UserPageColumns"/>
FROM sys_user
WHERE deleted = 0
<if test="query.username != null and query.username != ''">
AND username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.status != null">
AND status = #{query.status}
</if>
ORDER BY create_time DESC
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
XML 的 namespace 必须与 Mapper 接口全限定名一致,自定义 SQL 的 id 必须与 Mapper 接口方法名一致。字段较多时建议使用 <sql> 片段复用查询列,避免多个 SQL 中重复维护字段列表。
驼峰命名配置
驼峰命名配置用于解决数据库字段名和 Java 属性名之间的命名差异。例如数据库字段 create_time 可以自动映射到 Java 属性 createTime。MyBatis-Plus 中 map-underscore-to-camel-case 默认值为 true,并且该配置还会影响最终 SQL 的 select 字段生成;如果数据库命名符合下划线规范,通常无需在每个字段上额外使用 @TableField 指定字段名。(MyBatis-Plus)
推荐显式配置:
mybatis-plus:
configuration:
# 开启下划线转驼峰映射
map-underscore-to-camel-case: true2
3
4
数据库字段与 Java 属性建议保持如下对应关系:
| 数据库字段 | Java 属性 |
|---|---|
id | id |
user_name | userName |
phone_number | phoneNumber |
create_time | createTime |
update_time | updateTime |
deleted | deleted |
对于不符合规则的字段,应在实体类中使用 @TableField 显式指定。
package io.github.atengk.module.system.user.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_user")
public class UserEntity {
/**
* 用户 ID
*/
private Long id;
/**
* 用户名称,数据库字段为 user_name,可自动映射
*/
private String userName;
/**
* 特殊字段示例,数据库字段与 Java 属性不符合常规映射时显式指定
*/
@TableField("last_login_ip_addr")
private String lastLoginIp;
/**
* 创建时间
*/
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
项目中应尽量统一数据库命名规范,优先使用小写下划线字段名。只有历史表、第三方表或特殊字段才使用 @TableField 单独处理。
主键策略配置
主键策略决定新增数据时主键如何生成。MyBatis-Plus 的全局主键策略配置项是 mybatis-plus.global-config.db-config.id-type,官方文档说明其默认值为 ASSIGN_ID,该策略默认使用雪花算法生成 ID,适用于 Long、Integer 或 String 类型主键。(MyBatis-Plus)
推荐全局配置如下:
mybatis-plus:
global-config:
db-config:
# 分布式项目默认推荐雪花 ID
id-type: ASSIGN_ID2
3
4
5
常见主键策略选择:
| 策略 | 适用场景 | 注意事项 |
|---|---|---|
ASSIGN_ID | 分布式系统、微服务、分库分表预留场景 | 推荐使用 Long 类型,前端展示时注意精度问题 |
AUTO | 单库单表、数据库自增主键 | 依赖数据库自增能力,迁移和分布式扩展较弱 |
INPUT | 外部系统传入主键、编码类主键 | 插入前必须手动设置 ID |
ASSIGN_UUID | 字符串 UUID 主键 | 索引体积较大,不适合高频写入核心表 |
如果使用数据库自增主键,可以在实体类中单独配置:
package io.github.atengk.module.system.user.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 用户实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_user")
public class UserEntity {
/**
* 用户 ID,使用数据库自增
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 用户名
*/
private String username;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
如果使用全局 ASSIGN_ID,实体类可以简化为:
package io.github.atengk.module.system.role.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 角色实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_role")
public class RoleEntity {
/**
* 角色 ID,跟随全局主键策略
*/
@TableId
private Long id;
/**
* 角色名称
*/
private String roleName;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
主键策略建议在项目早期确定,后期切换成本较高。普通后台管理系统可以使用 AUTO,分布式系统、微服务项目、需要提前兼容分库分表的系统建议使用 ASSIGN_ID。
SQL 日志配置
SQL 日志用于开发调试、问题定位和性能分析。开发环境可以输出 SQL 和参数,生产环境不建议输出完整 SQL,尤其不要输出包含手机号、身份证、地址、Token、密码、订单金额等敏感字段的 SQL 参数。Spring Boot 默认通过日志系统抽象配置日志,通常 Web 项目引入 spring-boot-starter-web 后会间接引入默认日志能力;如果只需要调整日志级别,可以使用 logging.level.* 配置。(Home)
开发环境可以使用 MyBatis 的 StdOutImpl 直接打印 SQL:
mybatis-plus:
configuration:
# 开发环境可开启,生产环境禁止使用
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
# Mapper 接口日志
io.github.atengk.**.mapper: debug2
3
4
5
6
7
8
9
这种方式简单直接,但日志格式不可控,生产环境不推荐。更常见的方式是通过日志框架控制 Mapper 包日志级别:
logging:
level:
# MyBatis 内部日志
org.mybatis: warn
# MyBatis-Plus 日志
com.baomidou.mybatisplus: warn
# 项目 Mapper SQL 日志,开发环境可设置 debug
io.github.atengk.**.mapper: debug2
3
4
5
6
7
8
9
10
如果需要更规范地管理控制台和文件日志,可以使用 logback-spring.xml。
文件位置:src/main/resources/logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 应用名称,从 Spring 配置中读取 -->
<springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="mybatis-plus-demo"/>
<!-- 日志目录,可通过环境变量 LOG_PATH 覆盖 -->
<property name="LOG_PATH" value="${LOG_PATH:-./logs}"/>
<!-- 控制台日志格式 -->
<property name="CONSOLE_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"/>
<!-- 文件日志格式 -->
<property name="FILE_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{50} - %msg%n"/>
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 普通业务日志文件 -->
<appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}/info.log</file>
<encoder>
<pattern>${FILE_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 按天滚动日志 -->
<fileNamePattern>${LOG_PATH}/${APP_NAME}/info.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 保留 30 天 -->
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<!-- 开发环境日志 -->
<springProfile name="dev">
<logger name="io.github.atengk" level="DEBUG"/>
<logger name="io.github.atengk.**.mapper" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<!-- 生产环境日志 -->
<springProfile name="prod">
<logger name="io.github.atengk" level="INFO"/>
<logger name="io.github.atengk.**.mapper" level="INFO"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE_INFO"/>
</root>
</springProfile>
</configuration>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
如果生产环境需要分析慢 SQL,应优先使用数据库慢查询日志、APM、连接池指标、SQL 审计平台或专门的 SQL 监控工具,不建议长期依赖应用日志打印完整 SQL。
Banner 与启动日志优化
Banner 与启动日志优化的目标是减少无效输出,让启动过程更容易观察核心信息。Spring Boot 支持通过 spring.main.banner-mode 关闭启动 Banner,也可以通过自定义 banner.txt 替换默认 Banner。官方文档给出了 spring.main.banner-mode=off 或 YAML 中 spring.main.banner-mode: "off" 的配置方式。(Home)
关闭 Spring Boot Banner:
spring:
main:
# 关闭 Spring Boot 启动 Banner
banner-mode: off
mybatis-plus:
global-config:
# 关闭 MyBatis-Plus Banner
banner: false2
3
4
5
6
7
8
9
如果团队希望保留项目 Banner,可以创建自定义 Banner。
文件位置:src/main/resources/banner.txt
============================================================
${spring.application.name}
Profile : ${spring.profiles.active}
Version : 1.0.0
============================================================2
3
4
5
启动日志建议控制以下内容:
| 日志类型 | 建议 |
|---|---|
| Spring Boot Banner | 生产环境关闭或替换为简洁项目 Banner |
| MyBatis-Plus Banner | 建议关闭 |
| SQL 日志 | 开发环境开启,生产环境关闭完整 SQL |
| Mapper Debug | 开发环境可开启,生产环境使用 info 或 warn |
| 第三方框架日志 | 生产环境避免 debug |
| 启动关键信息 | 保留端口、Profile、数据库连接池、接口文档地址等 |
如果需要在应用启动后打印简洁的启动信息,可以增加一个启动监听类。
文件位置:src/main/java/io/github/atengk/common/web/ApplicationStartedLogger.java
package io.github.atengk.common.web;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
/**
* 应用启动完成日志
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ApplicationStartedLogger implements ApplicationListener<ApplicationReadyEvent> {
private final Environment environment;
/**
* 应用启动完成后输出关键访问信息
*
* @param event 应用启动完成事件
*/
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
String appName = environment.getProperty("spring.application.name", "application");
String port = environment.getProperty("server.port", "8080");
String contextPath = environment.getProperty("server.servlet.context-path", "");
String profile = StrUtil.join(",", environment.getActiveProfiles());
if (StrUtil.isBlank(profile)) {
profile = "default";
}
log.info("应用启动完成,应用名称:{},运行环境:{},访问地址:http://localhost:{}{}",
appName, profile, port, contextPath);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
这类启动日志只输出必要信息,不建议在启动时打印数据库密码、完整连接串、密钥、Token 或系统环境变量。生产环境中,启动日志应服务于运维排查,而不是暴露配置细节。
数据库设计规范
数据库设计规范用于统一表结构、字段命名、主键策略、审计字段、逻辑删除、乐观锁、租户隔离、状态字段、排序字段、索引和默认值设计。MyBatis-Plus 可以减少 CRUD 代码,但不能替代数据库建模、索引设计和约束设计。表结构一旦进入生产环境,后续调整成本较高,因此应在项目初始化阶段建立统一规范。
表命名规范
表名建议使用小写字母加下划线,避免使用大写、驼峰、中文、拼音缩写混用和数据库关键字。表名应表达业务含义,而不是表达技术分层。
推荐格式如下:
业务前缀_业务对象常见示例:
| 表名 | 说明 |
|---|---|
sys_user | 系统用户表 |
sys_role | 系统角色表 |
sys_menu | 系统菜单表 |
sys_user_role | 用户角色关联表 |
biz_order | 业务订单表 |
biz_order_item | 订单明细表 |
file_attachment | 附件记录表 |
log_operation | 操作日志表 |
命名建议如下:
| 规范项 | 建议 |
|---|---|
| 字符格式 | 全部小写,单词之间使用下划线 |
| 表名前缀 | 按业务域划分,例如 sys_、biz_、log_、file_ |
| 关联表 | 使用两个业务对象名组合,例如 sys_user_role |
| 历史表 | 可使用 _history 或 _his 后缀 |
| 临时表 | 可使用 tmp_ 前缀,仅限内部批处理场景 |
| 备份表 | 不建议长期保留在业务库中,确需保留可使用 _bak_日期 |
不建议使用如下命名:
User
userInfo
t_user
tb_user
sysUser
用户表
order2
3
4
5
6
7
其中 order 是 SQL 常见关键字,直接作为表名容易引起 SQL 可读性和兼容性问题。若业务对象确实叫订单,建议使用 biz_order 或 oms_order。
字段命名规范
字段名建议使用小写字母加下划线,并与 Java 实体类的驼峰属性形成稳定映射。例如数据库字段 create_time 对应 Java 属性 createTime。MyBatis-Plus 开启驼峰映射后,可以自动完成多数下划线字段到驼峰属性的映射。
推荐字段命名如下:
| 字段 | Java 属性 | 说明 |
|---|---|---|
id | id | 主键 |
user_name | userName | 用户名 |
phone_number | phoneNumber | 手机号 |
create_time | createTime | 创建时间 |
update_time | updateTime | 更新时间 |
create_by | createBy | 创建人 |
update_by | updateBy | 更新人 |
deleted | deleted | 逻辑删除字段 |
version | version | 乐观锁字段 |
tenant_id | tenantId | 租户 ID |
字段命名建议如下:
| 规范项 | 建议 |
|---|---|
| 命名格式 | 小写下划线 |
| 主键字段 | 统一使用 id |
| 外键字段 | 使用 对象_id,例如 user_id、role_id |
| 时间字段 | 使用 _time 后缀,例如 create_time、pay_time |
| 状态字段 | 使用 status 或明确业务前缀,例如 pay_status |
| 数量字段 | 使用 _count 或 _num,例如 login_count |
| 金额字段 | 使用 _amount,例如 pay_amount |
| 标识字段 | 使用 is_ 前缀时要谨慎,Java 属性可能出现命名歧义 |
不建议字段名使用数据库关键字,例如 desc、order、group、rank、type。如果确实需要表达类型,建议使用更明确的字段名,例如 user_type、order_type、biz_type。
主键字段设计
主键字段建议统一命名为 id。在 MyBatis-Plus 项目中,主键策略应与实体类 @TableId、全局 id-type 和数据库字段类型保持一致。
推荐主键设计如下:
| 场景 | 字段类型 | MyBatis-Plus 策略 | 说明 |
|---|---|---|---|
| 普通后台管理系统 | BIGINT | AUTO 或 ASSIGN_ID | 单体项目可用自增,分布式建议雪花 ID |
| 微服务系统 | BIGINT | ASSIGN_ID | 降低跨服务 ID 冲突风险 |
| 分库分表预留 | BIGINT | ASSIGN_ID | 更适合水平扩展 |
| 外部系统编码 | VARCHAR(64) | INPUT | 主键由外部系统传入 |
| 字典小表 | BIGINT | AUTO | 数据量小、单库场景可接受 |
推荐使用 BIGINT 作为主键类型。若使用 MyBatis-Plus ASSIGN_ID 雪花 ID,Java 类型通常使用 Long。前端如果使用 JavaScript,需要注意大整数精度问题,可以在返回给前端时将 Long 转为字符串。
主键字段示例:
id BIGINT NOT NULL COMMENT '主键ID'如果使用数据库自增:
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID'如果使用 MyBatis-Plus ASSIGN_ID,数据库字段不需要 AUTO_INCREMENT:
id BIGINT NOT NULL COMMENT '主键ID'主键设计不建议频繁变更。已上线系统从自增 ID 切换到雪花 ID,通常涉及历史数据、接口返回、前端精度、外部系统引用和数据同步链路,需要谨慎评估。
创建时间与更新时间字段
创建时间和更新时间属于审计字段,建议所有核心业务表统一保留。MyBatis-Plus 可以通过自动填充机制写入 create_time 和 update_time,避免每个新增或修改方法重复赋值。
推荐字段如下:
| 字段 | 类型 | 允许为空 | 默认值 | 说明 |
|---|---|---|---|---|
create_time | DATETIME | 否 | CURRENT_TIMESTAMP | 创建时间 |
update_time | DATETIME | 否 | CURRENT_TIMESTAMP | 更新时间 |
推荐 SQL:
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'2
对于 MyBatis-Plus 项目,推荐同时在实体类中使用自动填充:
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;2
3
4
5
数据库默认值和应用自动填充可以同时存在。应用层填充用于保证业务一致性,数据库默认值用于兜底,避免绕过应用写入数据时出现空值。
时间字段类型建议:
| 类型 | 建议 |
|---|---|
DATETIME | 常规业务系统推荐,表达业务时间 |
TIMESTAMP | 需要时区转换特性时再使用 |
BIGINT | 特殊高性能写入或跨语言时间戳场景 |
VARCHAR | 不推荐,排序和范围查询成本高 |
逻辑删除字段
逻辑删除用于保留历史数据,同时在业务查询中默认过滤已删除记录。MyBatis-Plus 支持通过 @TableLogic 或全局配置实现逻辑删除。
推荐字段如下:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
deleted | TINYINT | 0 | 是否删除:0 未删除,1 已删除 |
推荐 SQL:
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除'对应 MyBatis-Plus 全局配置:
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 02
3
4
5
6
逻辑删除字段设计注意事项:
| 场景 | 建议 |
|---|---|
| 普通业务表 | 建议保留 deleted 字段 |
| 日志表 | 可不使用逻辑删除,通常按时间归档或物理清理 |
| 关联表 | 视业务而定,核心关系建议保留逻辑删除 |
| 字典表 | 建议使用逻辑删除,避免历史数据引用丢失 |
| 大数据量流水表 | 谨慎使用逻辑删除,优先考虑分区、归档、物理清理 |
逻辑删除会影响唯一约束设计。例如用户表中 username 唯一,如果删除后允许重新创建同名用户,单独对 username 建唯一索引会冲突。此时可以考虑联合唯一索引:
UNIQUE KEY uk_username_deleted (username, deleted)但这种方式在同一个用户名被多次删除后仍可能冲突,因为多条已删除记录的 deleted 都是 1。更稳妥的方案是将逻辑删除字段设计为删除时间戳或删除批次号,例如 deleted_time,但会增加 MyBatis-Plus 默认逻辑删除配置复杂度。普通后台系统可以先使用 deleted,对强唯一业务单独设计删除策略。
乐观锁字段
乐观锁用于处理并发更新冲突。MyBatis-Plus 支持通过 @Version 注解和乐观锁插件实现版本号控制。适合库存扣减、订单状态流转、配置修改、审批状态变更等场景。
推荐字段如下:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
version | INT | 0 | 乐观锁版本号 |
推荐 SQL:
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'实体类字段示例:
@Version
private Integer version;2
乐观锁字段设计建议:
| 场景 | 是否建议使用 |
|---|---|
| 用户资料普通修改 | 可选 |
| 库存扣减 | 建议使用,或使用数据库原子更新 |
| 订单状态变更 | 建议使用 |
| 审批流转 | 建议使用 |
| 系统配置修改 | 建议使用 |
| 日志表写入 | 不需要 |
乐观锁不是万能并发控制方案。高并发库存扣减、秒杀、账户余额变更等场景,还需要结合数据库原子条件更新、Redis、消息队列、分布式锁或事务隔离策略综合设计。
租户字段
租户字段用于多租户系统的数据隔离。MyBatis-Plus 提供租户插件能力,可以在 SQL 中自动追加租户条件。数据库层面建议所有租户隔离表统一保留 tenant_id 字段。
推荐字段如下:
| 字段 | 类型 | 允许为空 | 说明 |
|---|---|---|---|
tenant_id | BIGINT | 否 | 租户 ID |
推荐 SQL:
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID'租户字段设计建议:
| 场景 | 建议 |
|---|---|
| SaaS 多租户业务表 | 必须保留 tenant_id |
| 用户表 | 多租户用户体系建议保留 |
| 角色、菜单、字典 | 视租户隔离策略决定 |
| 全局配置表 | 可以不加租户字段 |
| 系统公共字典 | 可以不加租户字段 |
| 日志表 | 建议保留,便于租户维度审计 |
租户字段通常需要参与索引设计。常见查询会按 tenant_id、deleted、status 和业务字段组合过滤,因此索引设计不能只看单一业务字段。
示例:
KEY idx_tenant_status_time (tenant_id, status, create_time)多租户系统中,不建议只依赖应用层传参控制租户隔离。应将租户上下文、MyBatis-Plus 租户插件、接口鉴权、数据权限、定时任务上下文和异步线程上下文统一设计。
状态字段
状态字段用于描述业务对象当前状态。状态字段应使用明确的业务语义,避免一个字段同时承载多个维度的状态。
常见状态字段如下:
| 字段 | 说明 |
|---|---|
status | 通用状态 |
user_status | 用户状态 |
order_status | 订单状态 |
pay_status | 支付状态 |
audit_status | 审核状态 |
enable_status | 启停状态 |
推荐字段类型:
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用'状态字段设计建议:
| 规范项 | 建议 |
|---|---|
| 类型 | 少量枚举值使用 TINYINT 或 SMALLINT |
| 默认值 | 必须设置明确默认值 |
| 注释 | 必须写清楚每个状态值含义 |
| Java 映射 | 推荐使用枚举映射 |
| 前端回显 | 推荐结合字典或枚举说明 |
| 状态扩展 | 避免随意复用旧状态值 |
不建议用字符串直接存储状态,例如 ENABLE、DISABLE。字符串状态可读性较好,但占用空间更大,索引效率较低,且容易出现大小写、拼写和兼容问题。若项目对可读性要求高,也可以使用字符串枚举,但必须统一规范。
状态字段不应混合多个业务维度。例如订单表不建议只用一个 status 同时表示“订单状态、支付状态、发货状态、退款状态”。更合理的设计是拆成 order_status、pay_status、delivery_status、refund_status。
排序字段
排序字段用于支持后台管理中的拖拽排序、菜单排序、字典项排序、分类排序等场景。排序字段建议统一命名为 sort_order 或 sort,但需要注意 order 是关键字,不建议直接使用。
推荐字段:
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值,值越小越靠前'排序字段设计建议:
| 场景 | 建议 |
|---|---|
| 菜单表 | 建议保留 |
| 字典项表 | 建议保留 |
| 分类表 | 建议保留 |
| 角色表 | 可选 |
| 用户表 | 通常不需要 |
| 订单表 | 通常按创建时间或业务时间排序,不需要专门排序字段 |
排序规则建议统一为:
sort_order ASC, create_time DESC也就是先按排序值升序,再按创建时间倒序。这样可以避免多个记录排序值相同时返回顺序不稳定。
查询示例:
ORDER BY sort_order ASC, create_time DESC排序字段不要使用过小的数据类型。建议使用 INT,便于后续插入中间排序值或批量调整。
备注字段
备注字段用于保存人工录入的补充说明。建议统一命名为 remark,长度根据业务需要选择。
推荐字段:
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注'备注字段设计建议:
| 场景 | 建议 |
|---|---|
| 普通业务备注 | VARCHAR(500) |
| 较长说明 | VARCHAR(1000) 或 TEXT |
| 富文本内容 | 单独内容表或 TEXT |
| 日志明细 | 可使用 TEXT,但注意查询性能 |
| 敏感备注 | 需要脱敏、权限控制或加密 |
不建议所有表都默认添加超大 TEXT 备注字段。大字段会影响查询性能、备份体积和缓存效率。对于列表查询,应避免查询大字段;对于详情接口,可以按需查询。
索引设计
索引用于提升查询性能,但索引不是越多越好。每个索引都会增加写入成本、占用存储空间,并影响更新和删除性能。索引设计应基于真实查询条件、排序条件、关联条件和唯一性约束。
常见索引命名规范如下:
| 索引类型 | 命名格式 | 示例 |
|---|---|---|
| 主键 | PRIMARY KEY | PRIMARY KEY (id) |
| 唯一索引 | uk_字段 | uk_username |
| 普通索引 | idx_字段 | idx_create_time |
| 联合索引 | idx_字段1_字段2 | idx_tenant_status_time |
常见索引设计原则:
| 原则 | 说明 |
|---|---|
| 高频查询字段建索引 | 登录名、手机号、订单号、租户 ID 等 |
| 高频排序字段考虑索引 | create_time、sort_order 等 |
| JOIN 字段建索引 | 关联表中的 user_id、role_id 等 |
| 区分度低字段不单独建索引 | deleted、status 单独索引价值通常较低 |
| 联合索引遵循最左前缀 | 按常见查询条件顺序设计 |
| 避免重复索引 | idx_a 和 idx_a_b 可能存在冗余 |
| 控制索引数量 | 写多读少表尤其要谨慎 |
多租户业务表常见联合索引:
KEY idx_tenant_status_time (tenant_id, status, create_time)逻辑删除业务表常见联合索引:
KEY idx_deleted_time (deleted, create_time)用户表登录查询唯一索引:
UNIQUE KEY uk_username (username)订单表业务编号唯一索引:
UNIQUE KEY uk_order_no (order_no)索引设计应与实际 SQL 一起评估。例如以下查询:
SELECT id, username, nickname
FROM sys_user
WHERE tenant_id = 1001
AND deleted = 0
AND status = 1
ORDER BY create_time DESC;2
3
4
5
6
可以考虑联合索引:
KEY idx_tenant_deleted_status_time (tenant_id, deleted, status, create_time)但如果 deleted 和 status 区分度很低,索引顺序需要结合数据量、查询频率和执行计划评估。不要机械地把所有 WHERE 字段都塞进联合索引。
唯一约束设计
唯一约束用于保证业务唯一性。与只在应用层校验相比,数据库唯一约束是最后一道数据一致性防线。对于用户名、手机号、邮箱、订单号、角色编码、字典编码等字段,应优先考虑数据库唯一约束。
常见唯一约束如下:
| 场景 | 唯一索引 |
|---|---|
| 用户名唯一 | uk_username (username) |
| 手机号唯一 | uk_phone (phone) |
| 订单号唯一 | uk_order_no (order_no) |
| 角色编码唯一 | uk_role_code (role_code) |
| 字典类型唯一 | uk_dict_type (dict_type) |
| 租户内用户名唯一 | uk_tenant_username (tenant_id, username) |
多租户系统中,唯一性通常要带上 tenant_id。例如不同租户允许存在相同用户名,则应使用:
UNIQUE KEY uk_tenant_username (tenant_id, username)如果用户名是全平台唯一,则使用:
UNIQUE KEY uk_username (username)逻辑删除与唯一约束需要重点设计。常见处理方式如下:
| 方案 | 说明 | 适用性 |
|---|---|---|
| 不允许删除后复用唯一字段 | 直接对业务字段建唯一索引 | 适合用户名、订单号等强唯一场景 |
| 删除后允许复用 | 唯一索引中加入删除标识或删除时间 | 适合分类编码、配置编码等场景 |
| 删除时改写唯一字段 | 删除时将编码追加删除后缀 | 实现简单,但需要统一封装 |
| 物理删除 | 直接释放唯一字段 | 适合临时数据,不适合核心业务 |
对于用户、订单、支付流水等核心数据,不建议为了复用唯一字段而破坏历史数据可追溯性。
字段默认值设计
字段默认值用于保证数据完整性,减少空值判断。业务表中除确实允许为空的字段外,建议尽量设置 NOT NULL 和合理默认值。这样可以降低 Java 代码中的空指针风险,也便于后续统计分析。
常见默认值建议如下:
| 字段类型 | 推荐默认值 | 示例 |
|---|---|---|
| 字符串 | '' | nickname VARCHAR(64) NOT NULL DEFAULT '' |
| 整数 | 0 | login_count INT NOT NULL DEFAULT 0 |
| 金额 | 0.00 | pay_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 |
| 状态 | 明确业务默认状态 | status TINYINT NOT NULL DEFAULT 1 |
| 逻辑删除 | 0 | deleted TINYINT NOT NULL DEFAULT 0 |
| 乐观锁 | 0 | version INT NOT NULL DEFAULT 0 |
| 排序 | 0 | sort_order INT NOT NULL DEFAULT 0 |
| 创建时间 | CURRENT_TIMESTAMP | create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
| 更新时间 | CURRENT_TIMESTAMP | update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP |
默认值设计建议:
| 规范项 | 建议 |
|---|---|
| 核心字段 | 尽量 NOT NULL |
| 可选文本 | 使用空字符串或允许为空,团队内统一 |
| 金额字段 | 必须设置默认值,避免空值参与计算 |
| 状态字段 | 必须有默认状态 |
| 时间字段 | 创建时间和更新时间建议数据库兜底 |
| JSON 字段 | 谨慎设置默认值,MySQL 版本兼容需确认 |
| 大字段 | 不建议设置复杂默认值 |
需要注意,默认值不能替代业务校验。例如 status 默认值可以防止空状态,但新增接口仍然需要校验状态是否合法。金额字段默认 0.00 可以避免空值计算异常,但不能替代支付金额、订单金额的业务规则校验。
下面给出一个符合上述规范的用户表示例,可作为后台管理系统业务表模板。
CREATE TABLE sys_user (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
username VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
phone VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号',
email VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值,值越小越靠前',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_username (tenant_id, username),
KEY idx_tenant_status_time (tenant_id, status, create_time),
KEY idx_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统用户表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
这张表适合作为普通业务表模板,但不是所有表都必须照搬。日志表可以不使用逻辑删除和乐观锁;关联表可以减少备注、排序等字段;配置表和字典表通常需要编码唯一约束;订单、支付、库存类表需要更严格的金额、状态和并发控制设计。
实体类设计
实体类用于描述 Java 对象与数据库表之间的映射关系。MyBatis-Plus 的实体设计应围绕表名、主键、字段映射、逻辑删除、乐观锁、枚举、自动填充、时间类型、金额类型和 JSON 字段展开。MyBatis-Plus 官方注解文档中,@TableName、@TableId、@TableField、@TableLogic、@Version、@EnumValue 等注解都属于实体映射中的核心注解。(MyBatis-Plus)
Entity 基础规范
Entity 只负责数据库表结构映射,不建议直接作为接口入参或接口返回对象。新增、修改、查询条件和返回数据应分别使用 DTO、Query、VO 等对象承载,避免数据库字段直接暴露给前端。
Entity 设计建议如下:
| 规范项 | 建议 |
|---|---|
| 类名 | 使用业务名 + Entity,例如 UserEntity |
| 表名映射 | 使用 @TableName 显式指定 |
| 主键字段 | 使用 @TableId 显式指定 |
| 普通字段 | 字段名符合驼峰规则时可省略 @TableField |
| 非表字段 | 使用 @TableField(exist = false) |
| 逻辑删除 | 使用全局配置或 @TableLogic |
| 乐观锁 | 使用 @Version |
| 枚举入库 | 使用 @EnumValue 或实现 IEnum |
| 时间类型 | Java 使用 LocalDateTime |
| 金额类型 | Java 使用 BigDecimal |
| JSON 字段 | 配合 typeHandler 和 autoResultMap |
推荐一个基础实体结构如下:
src/main/java/io/github/atengk/common/entity/BaseEntity.java
src/main/java/io/github/atengk/module/system/user/entity/UserEntity.java
src/main/java/io/github/atengk/module/system/user/enums/UserStatusEnum.java
src/main/java/io/github/atengk/module/system/user/model/UserExtraInfo.java2
3
4
Entity 不建议包含复杂业务方法。实体类可以包含少量状态判断方法,但核心业务规则应放在 Service 层或领域服务中,避免实体对象变成数据库字段和业务逻辑混杂的“大对象”。
TableName 注解
@TableName 用于指定实体类对应的数据库表名。MyBatis-Plus 官方注解文档中,@TableName 的 value 用于指定表名,schema 用于指定数据库 schema,keepGlobalPrefix 用于配合全局表名前缀,resultMap 和 autoResultMap 可用于复杂映射场景。(MyBatis-Plus)
普通表映射示例:
package io.github.atengk.module.system.user.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 用户实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_user")
public class UserEntity {
/**
* 用户名
*/
private String username;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
如果实体中包含 JSON 字段、复杂 TypeHandler 或自定义映射,通常需要开启 autoResultMap。MyBatis-Plus 字段类型处理器文档说明,使用 JSON TypeHandler 时,需要在实体类上配置 @TableName(autoResultMap = true),并在字段上指定对应的 typeHandler。(MyBatis-Plus)
@TableName(value = "sys_user", autoResultMap = true)
public class UserEntity {
}2
3
@TableName 使用建议:
| 场景 | 写法 |
|---|---|
| 普通业务表 | @TableName("sys_user") |
| JSON 字段映射 | @TableName(value = "sys_user", autoResultMap = true) |
| 使用 XML resultMap | @TableName(value = "sys_user", resultMap = "UserResultMap") |
| 使用全局表前缀 | 可配合 keepGlobalPrefix = true |
即使表名和类名能通过规则推断,也建议显式写出 @TableName,便于阅读和重构。
TableId 注解
@TableId 用于标识主键字段。MyBatis-Plus 官方注解文档中,@TableId 的 value 用于指定主键列名,type 用于指定主键策略。常见主键策略包括数据库自增、手动输入、雪花 ID、UUID 等。(MyBatis-Plus)
使用全局主键策略时:
@TableId
private Long id;2
显式指定数据库字段和策略时:
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;2
数据库自增主键:
@TableId(value = "id", type = IdType.AUTO)
private Long id;2
主键策略选择建议:
| 策略 | 适用场景 |
|---|---|
IdType.ASSIGN_ID | 分布式系统、微服务、未来可能分库分表的项目 |
IdType.AUTO | 单体项目、单库单表、依赖数据库自增的项目 |
IdType.INPUT | 外部系统传入主键、业务编码作为主键 |
IdType.ASSIGN_UUID | 字符串 UUID 主键,不推荐用于高频核心业务表 |
推荐在全局配置中统一主键策略,特殊表再通过 @TableId(type = ...) 单独覆盖。不要在同一个项目中混用过多主键策略,否则会增加数据迁移、接口返回和排查问题的复杂度。
TableField 注解
@TableField 用于处理非主键字段映射。MyBatis-Plus 官方注解文档中,@TableField 支持指定字段名、字段是否存在、自动填充策略、插入策略、更新策略、查询策略、是否参与查询、JDBC 类型、TypeHandler 等能力。(MyBatis-Plus)
常见用法如下:
@TableField("user_name")
private String username;2
当数据库字段符合下划线命名、Java 属性符合驼峰命名,并且开启了驼峰映射时,可以省略:
// 数据库字段 user_name 可自动映射到 username
private String userName;2
字段不参与查询:
@TableField(select = false)
private String password;2
非数据库字段:
@TableField(exist = false)
private String roleName;2
更新时强制设置字段:
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String remark;2
@TableField 常见属性建议:
| 属性 | 说明 | 常见场景 |
|---|---|---|
value | 数据库字段名 | 字段名不符合驼峰规则 |
exist | 是否为数据库字段 | VO 辅助字段、临时字段 |
select | 是否参与查询 | 密码、密钥、大字段 |
fill | 自动填充策略 | 创建时间、更新时间、创建人、更新人 |
insertStrategy | 插入策略 | 控制空字段是否入库 |
updateStrategy | 更新策略 | 控制空字段是否更新 |
typeHandler | 类型处理器 | JSON 字段、特殊类型映射 |
jdbcType | JDBC 类型 | 特殊数据库字段映射 |
敏感字段建议使用 select = false,例如密码、盐值、密钥等。但这不是安全控制的唯一手段,接口返回对象仍然不应直接返回 Entity。
TableLogic 注解
@TableLogic 用于标记逻辑删除字段。MyBatis-Plus 官方注解文档中,@TableLogic 支持 value 和 delval,分别表示未删除值和已删除值。项目中也可以通过全局配置统一设置逻辑删除字段。(MyBatis-Plus)
实体字段写法:
@TableLogic(value = "0", delval = "1")
private Integer deleted;2
如果已经配置全局逻辑删除字段,可以简化为:
@TableLogic
private Integer deleted;2
全局配置示例:
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 02
3
4
5
6
逻辑删除字段建议使用:
private Integer deleted;不建议使用 Boolean 作为逻辑删除字段。虽然 Java 表达上更直观,但数据库中通常使用 TINYINT 保存 0 和 1,使用 Integer 更利于和 SQL、索引、全局配置保持一致。
Version 注解
@Version 用于标记乐观锁版本字段。MyBatis-Plus 官方注解文档中,@Version 是乐观锁注解,需要标记在版本字段上。(MyBatis-Plus)
实体字段写法:
@Version
private Integer version;2
数据库字段建议:
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'乐观锁适合以下场景:
| 场景 | 建议 |
|---|---|
| 订单状态修改 | 建议使用 |
| 库存扣减 | 建议使用,或使用条件更新 |
| 审批状态流转 | 建议使用 |
| 系统配置修改 | 建议使用 |
| 普通日志写入 | 不需要 |
| 只新增不更新的流水表 | 通常不需要 |
乐观锁只在更新时生效,并且需要配置对应的乐观锁拦截器。后续「乐观锁」章节中应单独说明 OptimisticLockerInnerInterceptor 的配置和冲突处理。
EnumValue 注解
@EnumValue 用于标记枚举中真正入库的字段。MyBatis-Plus 官方注解文档中,@EnumValue 是枚举字段注解,用于指定枚举映射到数据库时使用的真实值。(MyBatis-Plus)
文件位置:src/main/java/io/github/atengk/module/system/user/enums/UserStatusEnum.java
package io.github.atengk.module.system.user.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 用户状态枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum UserStatusEnum {
/**
* 禁用
*/
DISABLED(0, "禁用"),
/**
* 启用
*/
ENABLED(1, "启用");
/**
* 入库值
*/
@EnumValue
private final Integer code;
/**
* 展示名称
*/
private final String name;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
实体类中直接使用枚举类型:
private UserStatusEnum status;对应数据库字段:
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用'枚举字段设计建议:
| 规范项 | 建议 |
|---|---|
| 数据库存储 | 推荐存储数字编码 |
| Java 类型 | 推荐使用枚举 |
| 入库字段 | 使用 @EnumValue 标记 |
| 接口返回 | 不建议直接返回枚举对象,建议转为编码和名称 |
| 前端回显 | 结合字典或 VO 字段返回 |
| 枚举变更 | 禁止随意修改已入库编码含义 |
枚举编码一旦入库,不应随意修改含义。例如 1 原本表示启用,后续不能改成冻结,否则历史数据语义会被破坏。
FieldFill 自动填充
FieldFill 用于控制字段在插入或更新时的自动填充策略。MyBatis-Plus 官方注解文档中,FieldFill.DEFAULT 表示默认不处理,INSERT 表示插入时填充,UPDATE 表示更新时填充,INSERT_UPDATE 表示插入和更新时都填充。(MyBatis-Plus)
常见自动填充字段:
@TableField(fill = FieldFill.INSERT)
private Long createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;2
3
4
5
6
7
8
9
10
11
自动填充需要配合 MetaObjectHandler 实现。示例代码放在框架配置包中。
文件位置:src/main/java/io/github/atengk/framework/mybatis/MyBatisPlusMetaObjectHandler.java
package io.github.atengk.framework.mybatis;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 字段自动填充处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class MyBatisPlusMetaObjectHandler implements MetaObjectHandler {
/**
* 插入数据时自动填充创建和更新字段
*
* @param metaObject 元对象
*/
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
Long currentUserId = getCurrentUserId();
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "createBy", Long.class, currentUserId);
this.strictInsertFill(metaObject, "updateBy", Long.class, currentUserId);
log.debug("执行新增字段自动填充,当前用户ID:{}", currentUserId);
}
/**
* 更新数据时自动填充更新字段
*
* @param metaObject 元对象
*/
@Override
public void updateFill(MetaObject metaObject) {
Long currentUserId = getCurrentUserId();
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
this.strictUpdateFill(metaObject, "updateBy", Long.class, currentUserId);
log.debug("执行更新字段自动填充,当前用户ID:{}", currentUserId);
}
/**
* 获取当前登录用户 ID
*
* @return 当前用户 ID,未登录时返回 0
*/
private Long getCurrentUserId() {
// 示例项目先返回 0;实际项目中可从 Sa-Token、Spring Security 或用户上下文中获取
Long userId = 0L;
return ObjectUtil.defaultIfNull(userId, 0L);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
自动填充常见失效原因:
| 原因 | 说明 |
|---|---|
字段未加 fill | 实体字段没有配置 FieldFill |
| 未注册处理器 | 没有实现或扫描到 MetaObjectHandler |
| 字段名不一致 | strictInsertFill 中字段名与实体属性名不一致 |
| 手写 SQL 绕过 | 部分自定义 SQL 不触发预期填充 |
| 类型不一致 | 填充值类型与实体字段类型不一致 |
| 手动设置了 null | 严格填充策略可能不会覆盖已有值 |
自动填充建议用于审计字段,不建议用于复杂业务字段。业务状态、订单金额、库存数量等字段应由业务逻辑明确赋值。
Transient 非表字段处理
非表字段用于在 Entity 中临时承载查询结果、关联名称、计算值或程序中间值。MyBatis-Plus 中推荐使用 @TableField(exist = false) 明确声明字段不属于数据库表字段。
示例:
@TableField(exist = false)
private String roleName;2
多个角色名称示例:
@TableField(exist = false)
private List<String> roleNames;2
不建议只依赖 Java 的 transient 关键字处理数据库非表字段。transient 的主要语义是参与 Java 序列化控制,而不是表达 ORM 映射关系。为了让代码意图更清晰,MyBatis-Plus 实体中建议统一使用 @TableField(exist = false)。
非表字段使用建议:
| 场景 | 建议 |
|---|---|
| 关联名称 | 可以临时使用,但更推荐放到 VO |
| 统计值 | 可以用于自定义 SQL 接收结果 |
| 前端展示字段 | 推荐放到 VO,不推荐长期放在 Entity |
| 请求参数字段 | 应放到 DTO 或 Query |
| 缓存辅助字段 | 谨慎使用,避免污染实体语义 |
Entity 中非表字段不宜过多。若一个实体中出现大量 exist = false 字段,通常说明应新建 VO 或自定义查询结果对象。
LocalDateTime 时间字段处理
Spring Boot 3 项目中,业务时间字段建议使用 LocalDateTime。数据库中常用 DATETIME 类型保存创建时间、更新时间、支付时间、审核时间、登录时间等。
实体字段示例:
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;2
3
4
5
数据库字段示例:
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'2
接口 JSON 格式建议在全局配置中统一处理:
spring:
jackson:
# 统一接口时间格式
date-format: yyyy-MM-dd HH:mm:ss
# 统一时区
time-zone: Asia/Shanghai2
3
4
5
6
时间字段设计建议:
| 场景 | Java 类型 | 数据库类型 |
|---|---|---|
| 创建时间 | LocalDateTime | DATETIME |
| 更新时间 | LocalDateTime | DATETIME |
| 生日 | LocalDate | DATE |
| 日内时间 | LocalTime | TIME |
| 时间戳 | Long | BIGINT |
| 跨时区瞬时时间 | Instant | 视项目规范决定 |
普通后台管理系统建议统一使用 LocalDateTime + DATETIME。跨国家、跨时区系统需要单独设计时区策略,避免只依赖默认 JVM 时区。
BigDecimal 金额字段处理
金额、价格、费率、折扣、重量、面积等需要精确计算的字段,Java 中应使用 BigDecimal,数据库中应使用 DECIMAL。不要使用 Float 或 Double 保存金额,因为二进制浮点数会产生精度问题。
实体字段示例:
private BigDecimal orderAmount;
private BigDecimal payAmount;2
3
数据库字段示例:
order_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额',
pay_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '支付金额'2
金额字段设计建议:
| 场景 | 数据库类型 | 说明 |
|---|---|---|
| 普通金额 | DECIMAL(18,2) | 常规订单金额 |
| 单价 | DECIMAL(18,4) | 单价可能需要更多小数位 |
| 汇率 | DECIMAL(18,6) | 汇率精度更高 |
| 重量 | DECIMAL(18,3) | 根据业务单位决定 |
| 比例 | DECIMAL(10,4) | 折扣、税率、费率 |
金额计算建议统一封装,避免在业务代码中散落四舍五入逻辑。简单示例:
BigDecimal actualAmount = orderAmount.subtract(discountAmount).max(BigDecimal.ZERO);比较金额时不要使用 equals,因为 BigDecimal("1.0") 和 BigDecimal("1.00") 的 scale 不同。建议使用 compareTo:
if (payAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("支付金额不能小于0");
}2
3
金额字段应尽量设置 NOT NULL DEFAULT 0.00,避免空值参与计算。
JSON 字段映射
JSON 字段适合保存扩展信息、配置项、第三方回调原文、非核心筛选字段等。MyBatis-Plus 提供 JSON 类型处理器,例如 JacksonTypeHandler、Fastjson2TypeHandler、GsonTypeHandler 等,可以通过 @TableField(typeHandler = ...) 注入。官方类型处理器文档说明,JSON 字段映射需要选择对应的 JSON 处理器,并确保项目中存在对应 JSON 解析依赖。(MyBatis-Plus)
数据库字段示例:
extra_info JSON NULL COMMENT '扩展信息'扩展信息模型:
文件位置:src/main/java/io/github/atengk/module/system/user/model/UserExtraInfo.java
package io.github.atengk.module.system.user.model;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 用户扩展信息
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserExtraInfo implements Serializable {
/**
* 最近登录 IP
*/
private String lastLoginIp;
/**
* 最近登录时间
*/
private LocalDateTime lastLoginTime;
/**
* 注册来源
*/
private String registerSource;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
实体类 JSON 字段示例:
package io.github.atengk.module.system.user.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.github.atengk.module.system.user.model.UserExtraInfo;
import lombok.Getter;
import lombok.Setter;
/**
* 用户实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName(value = "sys_user", autoResultMap = true)
public class UserEntity {
/**
* 用户扩展信息,数据库字段为 JSON
*/
@TableField(value = "extra_info", typeHandler = JacksonTypeHandler.class)
private UserExtraInfo extraInfo;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
JSON 字段使用建议:
| 场景 | 建议 |
|---|---|
| 扩展信息 | 适合 |
| 第三方回调原文 | 适合 |
| 页面配置 | 适合 |
| 高频查询条件 | 不建议 |
| 强约束核心字段 | 不建议 |
| 需要索引排序字段 | 不建议直接放 JSON |
JSON 字段不应滥用。凡是需要频繁查询、排序、统计、唯一约束或关联的数据,应拆成独立字段或独立表。
枚举字段映射
枚举字段映射用于让 Java 代码使用有语义的枚举类型,同时数据库中保存稳定编码。MyBatis-Plus 推荐通过 @EnumValue 或相关枚举接口处理枚举入库映射。@EnumValue 会指定枚举中真正写入数据库的属性。(MyBatis-Plus)
用户状态枚举:
package io.github.atengk.module.system.user.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 用户状态枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum UserStatusEnum {
/**
* 禁用
*/
DISABLED(0, "禁用"),
/**
* 启用
*/
ENABLED(1, "启用");
/**
* 数据库存储值
*/
@EnumValue
private final Integer code;
/**
* 展示名称
*/
private final String name;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
实体字段:
private UserStatusEnum status;枚举字段返回给前端时,不建议直接把枚举对象暴露出去。建议在 VO 中拆成编码和名称:
private Integer status;
private String statusName;2
3
转换时可以显式处理:
UserStatusEnum status = userEntity.getStatus();
userVO.setStatus(status.getCode());
userVO.setStatusName(status.getName());2
3
枚举字段设计注意事项:
| 问题 | 建议 |
|---|---|
| 枚举编码变更 | 已入库编码禁止修改含义 |
| 枚举删除 | 不建议删除历史编码,可标记废弃 |
| 前端回显 | 返回编码和名称 |
| 字典化管理 | 业务状态稳定用枚举,运营可配置项用字典 |
| 数据库注释 | 写清楚状态值含义 |
稳定、低频变更的状态适合用枚举;需要运营后台动态维护的值更适合用字典表。
公共父类 BaseEntity
公共父类用于抽取大多数业务表都会拥有的字段,例如主键、创建人、创建时间、更新人、更新时间、逻辑删除和乐观锁。这样可以减少重复字段声明,并统一实体规范。
文件位置:src/main/java/io/github/atengk/common/entity/BaseEntity.java
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 实体公共父类
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class BaseEntity implements Serializable {
/**
* 主键 ID
*/
@TableId
private Long id;
/**
* 创建人 ID
*/
@TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新人 ID
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 逻辑删除标识:0未删除,1已删除
*/
@TableLogic
private Integer deleted;
/**
* 乐观锁版本号
*/
@Version
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
基于 BaseEntity 的完整用户实体示例:
文件位置:src/main/java/io/github/atengk/module/system/user/entity/UserEntity.java
package io.github.atengk.module.system.user.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.github.atengk.common.entity.BaseEntity;
import io.github.atengk.module.system.user.enums.UserStatusEnum;
import io.github.atengk.module.system.user.model.UserExtraInfo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.util.List;
/**
* 用户实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName(value = "sys_user", autoResultMap = true)
public class UserEntity extends BaseEntity {
/**
* 租户 ID
*/
private Long tenantId;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 手机号
*/
private String phone;
/**
* 邮箱
*/
private String email;
/**
* 密码,不参与默认查询
*/
@TableField(select = false)
private String password;
/**
* 用户余额
*/
private BigDecimal balanceAmount;
/**
* 用户状态
*/
private UserStatusEnum status;
/**
* 排序值,值越小越靠前
*/
private Integer sortOrder;
/**
* 用户扩展信息
*/
@TableField(value = "extra_info", typeHandler = JacksonTypeHandler.class)
private UserExtraInfo extraInfo;
/**
* 备注
*/
private String remark;
/**
* 角色名称列表,非数据库字段
*/
@TableField(exist = false)
private List<String> roleNames;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
对应建表示例:
CREATE TABLE sys_user (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
username VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
phone VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号',
email VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',
password VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码',
balance_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '用户余额',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值,值越小越靠前',
extra_info JSON NULL COMMENT '扩展信息',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_username (tenant_id, username),
KEY idx_tenant_status_time (tenant_id, status, create_time),
KEY idx_phone (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统用户表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
BaseEntity 使用建议:
| 场景 | 建议 |
|---|---|
| 普通业务表 | 继承 BaseEntity |
| 日志表 | 可单独设计,不一定需要乐观锁和逻辑删除 |
| 关联表 | 可按需继承,简单关联表也可只保留主键和审计字段 |
| 字典表 | 建议继承,便于审计和逻辑删除 |
| 流水表 | 通常不需要乐观锁,是否逻辑删除视业务而定 |
公共父类不要放业务字段,例如 tenantId 是否放入 BaseEntity 需要看系统是否所有表都强制多租户隔离。如果不是所有表都有租户字段,建议单独建立 TenantBaseEntity,避免全局实体继承后出现无意义字段。
DTO、VO、BO、Query 对象设计
DTO、VO、BO、Query 的设计目标是隔离不同层之间的数据结构,避免 Entity 被接口、业务逻辑和数据库映射同时复用。Entity 应只表达数据库表结构;DTO 负责接收外部入参;Query 负责承载查询条件;VO 负责接口返回;BO 负责业务内部流转。这样可以降低字段泄露风险,也便于参数校验、字段脱敏、接口版本演进和对象转换维护。
DTO 使用场景
DTO 是 Data Transfer Object,主要用于接收外部传入的数据,例如新增、修改、导入、审核、状态变更等请求参数。DTO 不应直接继承 Entity,也不应包含数据库专用字段,例如 deleted、version、createBy、createTime 等,除非该字段确实需要由前端参与业务控制。
常见 DTO 类型如下:
| DTO 类型 | 使用场景 |
|---|---|
AddDTO | 新增数据 |
UpdateDTO | 修改数据 |
ImportDTO | Excel 导入 |
AuditDTO | 审核操作 |
StatusDTO | 状态变更 |
BindDTO | 绑定关系 |
BatchDTO | 批量操作 |
DTO 设计建议如下:
| 规范项 | 建议 |
|---|---|
| 命名 | 使用业务名 + 操作 + DTO,例如 UserAddDTO |
| 校验 | 使用 Jakarta Validation 注解 |
| 字段范围 | 只保留接口允许传入的字段 |
| 敏感字段 | 密码、密钥等字段必须单独处理 |
| 默认值 | 可在 DTO 中设置简单默认值,复杂默认值放 Service |
| 继承 | 不建议继承 Entity |
| 转换 | DTO 转 Entity 推荐使用 MapStruct |
DTO 不要为了“省事”直接复用 Entity。直接复用 Entity 会导致前端可能传入不该由其控制的字段,例如主键、租户 ID、创建人、逻辑删除标识、乐观锁版本、余额、权限字段等。
VO 使用场景
VO 是 View Object,主要用于接口返回。VO 应面向前端展示和接口契约设计,不应被数据库表结构强行约束。对于需要脱敏、字典回显、枚举回显、关联名称、统计值、按钮权限等场景,应在 VO 中单独定义字段。
常见 VO 类型如下:
| VO 类型 | 使用场景 |
|---|---|
DetailVO | 详情接口返回 |
PageVO | 分页列表每一行返回 |
ListVO | 普通列表返回 |
OptionVO | 下拉选项返回 |
TreeVO | 树形结构返回 |
ExportVO | 导出数据返回 |
StatisticsVO | 统计结果返回 |
VO 设计建议如下:
| 规范项 | 建议 |
|---|---|
| 命名 | 使用业务名 + 场景 + VO |
| 字段范围 | 只返回前端需要的字段 |
| 敏感字段 | 不返回密码、盐值、密钥、Token |
| 枚举字段 | 建议同时返回编码和名称 |
| 字典字段 | 建议返回原始值和回显名称 |
| 时间字段 | 使用统一格式 |
| 金额字段 | 使用 BigDecimal,前端展示格式另行处理 |
例如用户详情可以返回角色名称、部门名称、状态名称,但这些字段并不一定存在于 sys_user 表中,因此应放在 VO 中,而不是污染 Entity。
BO 使用场景
BO 是 Business Object,主要用于业务内部流转。它通常不直接暴露给前端,也不直接映射数据库表,而是服务于复杂业务流程,例如订单创建、库存扣减、支付回调、审批流转、权限计算、导入解析等。
BO 适合以下场景:
| 场景 | 示例 |
|---|---|
| 多对象聚合 | OrderCreateBO 聚合订单、明细、优惠、收货地址 |
| 业务流程流转 | AuditContextBO 承载审核上下文 |
| 外部接口适配 | PayNotifyBO 承载支付回调解析结果 |
| 权限计算 | DataScopeBO 承载数据权限范围 |
| 导入处理 | ImportUserBO 承载导入行、错误信息、转换结果 |
BO 设计建议如下:
| 规范项 | 建议 |
|---|---|
| 命名 | 使用业务名 + 场景 + BO |
| 使用范围 | 仅在 Service、Domain、Handler 等内部层使用 |
| 参数校验 | 可以包含业务校验结果,不必完全依赖 Validation |
| 生命周期 | 随业务流程存在,不直接持久化 |
| 转换 | 可以由 DTO、Entity、外部回调对象转换而来 |
简单 CRUD 项目可以不强制引入 BO。只有当 DTO、Entity、VO 已经无法清晰表达业务流转数据时,再引入 BO,避免过度设计。
Query 查询对象使用场景
Query 对象用于承载查询条件,尤其是分页查询、多条件筛选、时间范围查询、排序查询和状态筛选。Query 与 DTO 的区别在于:DTO 通常表达写操作参数,Query 表达读操作参数。
Query 常见字段包括:
| 字段 | 说明 |
|---|---|
keyword | 通用关键字 |
status | 状态筛选 |
startTime | 开始时间 |
endTime | 结束时间 |
pageNum | 当前页 |
pageSize | 每页条数 |
orderBy | 排序字段 |
orderDirection | 排序方向 |
Query 设计建议如下:
| 规范项 | 建议 |
|---|---|
| 命名 | 使用业务名 + Query |
| 分页字段 | 可继承公共 PageQuery |
| 时间范围 | 使用 LocalDateTime |
| 排序字段 | 必须做白名单校验 |
| 空值处理 | Service 层使用 Hutool 判空 |
| 默认分页 | 设置合理默认值 |
| 最大页大小 | 限制最大 pageSize,防止大查询 |
前端传入的排序字段不能直接拼接到 SQL 中。排序字段必须通过白名单映射到数据库字段,避免 SQL 注入风险。
AddDTO 新增对象
新增对象用于接收创建数据的请求参数。新增 DTO 不应包含 id、deleted、version、createTime、updateTime 等数据库维护字段。
文件位置:src/main/java/io/github/atengk/module/system/user/dto/UserAddDTO.java
package io.github.atengk.module.system.user.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 用户新增参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserAddDTO {
/**
* 用户名
*/
@NotBlank(message = "用户名不能为空")
@Size(max = 64, message = "用户名长度不能超过64个字符")
private String username;
/**
* 昵称
*/
@NotBlank(message = "昵称不能为空")
@Size(max = 64, message = "昵称长度不能超过64个字符")
private String nickname;
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空")
@Size(max = 20, message = "手机号长度不能超过20个字符")
private String phone;
/**
* 邮箱
*/
@Email(message = "邮箱格式不正确")
@Size(max = 128, message = "邮箱长度不能超过128个字符")
private String email;
/**
* 用户余额
*/
@NotNull(message = "用户余额不能为空")
@DecimalMin(value = "0.00", message = "用户余额不能小于0")
private BigDecimal balanceAmount;
/**
* 状态:0禁用,1启用
*/
@NotNull(message = "用户状态不能为空")
private Integer status;
/**
* 排序值,值越小越靠前
*/
private Integer sortOrder;
/**
* 备注
*/
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
新增 DTO 只校验参数格式和基础边界,例如非空、长度、金额范围。用户名是否重复、手机号是否重复、状态值是否合法、租户是否有权限创建用户,应放在 Service 层进行业务校验。
UpdateDTO 修改对象
修改对象用于接收更新数据的请求参数。修改 DTO 通常必须包含 id,并根据业务需要携带 version 做乐观锁控制。对于局部更新接口,DTO 中字段可以允许为空;对于完整更新接口,字段校验应接近新增 DTO。
文件位置:src/main/java/io/github/atengk/module/system/user/dto/UserUpdateDTO.java
package io.github.atengk.module.system.user.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 用户修改参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserUpdateDTO {
/**
* 用户 ID
*/
@NotNull(message = "用户ID不能为空")
private Long id;
/**
* 昵称
*/
@Size(max = 64, message = "昵称长度不能超过64个字符")
private String nickname;
/**
* 手机号
*/
@Size(max = 20, message = "手机号长度不能超过20个字符")
private String phone;
/**
* 邮箱
*/
@Email(message = "邮箱格式不正确")
@Size(max = 128, message = "邮箱长度不能超过128个字符")
private String email;
/**
* 用户余额
*/
@DecimalMin(value = "0.00", message = "用户余额不能小于0")
private BigDecimal balanceAmount;
/**
* 状态:0禁用,1启用
*/
private Integer status;
/**
* 排序值,值越小越靠前
*/
private Integer sortOrder;
/**
* 乐观锁版本号
*/
private Integer version;
/**
* 备注
*/
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
修改 DTO 是否允许字段为空,需要根据接口语义决定。如果接口语义是“局部修改”,空字段表示不修改;如果接口语义是“覆盖修改”,空字段可能表示清空字段。两种语义应拆成不同接口或在文档中明确,不要混用。
PageQuery 分页查询对象
分页查询对象用于封装通用分页参数。业务 Query 可以继承 PageQuery,避免每个查询对象重复定义 pageNum 和 pageSize。分页参数必须限制范围,防止前端传入过大的 pageSize 导致数据库压力过高。
文件位置:src/main/java/io/github/atengk/common/query/PageQuery.java
package io.github.atengk.common.query;
import cn.hutool.core.util.StrUtil;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.Setter;
import java.util.Set;
/**
* 通用分页查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class PageQuery {
/**
* 当前页
*/
@Min(value = 1, message = "当前页不能小于1")
private Long pageNum = 1L;
/**
* 每页条数
*/
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 200, message = "每页条数不能超过200")
private Long pageSize = 10L;
/**
* 排序字段
*/
private String orderBy;
/**
* 排序方向:asc 或 desc
*/
private String orderDirection = "desc";
/**
* 获取安全排序方向
*
* @return 排序方向
*/
public String getSafeOrderDirection() {
return StrUtil.equalsIgnoreCase(orderDirection, "asc") ? "asc" : "desc";
}
/**
* 校验排序字段是否在白名单内
*
* @param allowedColumns 允许排序的字段集合
* @return 是否允许排序
*/
public boolean isAllowedOrderBy(Set<String> allowedColumns) {
return StrUtil.isNotBlank(orderBy) && allowedColumns.contains(orderBy);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
业务分页查询对象可以继承 PageQuery。
文件位置:src/main/java/io/github/atengk/module/system/user/query/UserPageQuery.java
package io.github.atengk.module.system.user.query;
import io.github.atengk.common.query.PageQuery;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户分页查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserPageQuery extends PageQuery {
/**
* 用户名
*/
@Size(max = 64, message = "用户名长度不能超过64个字符")
private String username;
/**
* 手机号
*/
@Size(max = 20, message = "手机号长度不能超过20个字符")
private String phone;
/**
* 状态:0禁用,1启用
*/
private Integer status;
/**
* 创建开始时间
*/
private LocalDateTime startTime;
/**
* 创建结束时间
*/
private LocalDateTime endTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
分页查询对象不要直接接收数据库字段名用于排序。推荐前端传业务字段,例如 createTime,后端将其映射为数据库字段 create_time。
DetailVO 详情返回对象
详情 VO 用于返回单条数据的完整展示信息。详情对象通常字段比分页列表更多,可以包含关联信息、枚举名称、字典名称、角色列表、权限列表等。
文件位置:src/main/java/io/github/atengk/module/system/user/vo/UserDetailVO.java
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 用户详情返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserDetailVO {
/**
* 用户 ID
*/
private Long id;
/**
* 租户 ID
*/
private Long tenantId;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 脱敏手机号
*/
private String phone;
/**
* 邮箱
*/
private String email;
/**
* 用户余额
*/
private BigDecimal balanceAmount;
/**
* 状态编码
*/
private Integer status;
/**
* 状态名称
*/
private String statusName;
/**
* 角色名称列表
*/
private List<String> roleNames;
/**
* 排序值,值越小越靠前
*/
private Integer sortOrder;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 备注
*/
private String remark;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
详情 VO 可以返回业务需要的展示字段,但不应返回密码、盐值、密钥、逻辑删除字段等内部字段。手机号、身份证号、邮箱等敏感字段应根据权限决定是否脱敏。
PageVO 分页返回对象
分页返回通常包含两层对象:一层是通用分页包装对象,保存总数、页码、每页条数和列表;另一层是业务行数据 VO,例如 UserPageVO。这样可以统一所有分页接口的返回结构。
文件位置:src/main/java/io/github/atengk/common/vo/PageResult.java
package io.github.atengk.common.vo;
import lombok.Getter;
import lombok.Setter;
import java.util.Collections;
import java.util.List;
/**
* 通用分页返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class PageResult<T> {
/**
* 当前页
*/
private Long pageNum;
/**
* 每页条数
*/
private Long pageSize;
/**
* 总条数
*/
private Long total;
/**
* 数据列表
*/
private List<T> records;
/**
* 创建分页返回对象
*
* @param pageNum 当前页
* @param pageSize 每页条数
* @param total 总条数
* @param records 数据列表
* @param <T> 数据类型
* @return 分页返回对象
*/
public static <T> PageResult<T> of(Long pageNum, Long pageSize, Long total, List<T> records) {
PageResult<T> result = new PageResult<>();
result.setPageNum(pageNum);
result.setPageSize(pageSize);
result.setTotal(total);
result.setRecords(records == null ? Collections.emptyList() : records);
return result;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
文件位置:src/main/java/io/github/atengk/module/system/user/vo/UserPageVO.java
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户分页列表返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserPageVO {
/**
* 用户 ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 脱敏手机号
*/
private String phone;
/**
* 状态编码
*/
private Integer status;
/**
* 状态名称
*/
private String statusName;
/**
* 创建时间
*/
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
分页列表 VO 应尽量保持轻量,不建议返回大量长文本、大字段、JSON 字段和复杂关联集合。详情字段放到详情接口中返回,列表接口只保留筛选、展示和操作需要的字段。
对象转换规范
对象转换用于在 DTO、Entity、VO、BO 之间做结构转换。项目中应统一转换规范,避免在 Controller 和 Service 中大量手写 setXxx。对于字段较多、转换频繁、需要编译期检查的对象,推荐使用 MapStruct;对于字段简单、临时转换或内部工具场景,可以使用 Hutool BeanUtil。
推荐转换方向如下:
| 来源 | 目标 | 推荐方式 |
|---|---|---|
AddDTO | Entity | MapStruct |
UpdateDTO | Entity | MapStruct |
Entity | DetailVO | MapStruct |
Entity | PageVO | MapStruct |
Entity List | VO List | MapStruct |
| 简单对象复制 | 简单对象 | Hutool BeanUtil |
| 动态 Map 转对象 | DTO / BO | Hutool BeanUtil |
| 复杂业务聚合 | BO / VO | 手写或 MapStruct 表达式 |
对象转换建议:
| 规范项 | 建议 |
|---|---|
| Controller | 不直接转换 Entity,调用 Service 返回 VO |
| Service | 负责 DTO、Entity、VO、BO 转换调度 |
| Convert 包 | 每个业务模块维护自己的转换接口 |
| 敏感字段 | 转换时统一脱敏或不映射 |
| 枚举字段 | 转换为编码和名称 |
| 空值更新 | 修改场景要明确是否忽略空字段 |
| 集合转换 | 使用 MapStruct 或 Hutool 集合工具处理 |
对象转换不是业务校验。即使 DTO 已成功转成 Entity,仍然需要在 Service 层校验唯一性、状态合法性、权限边界和业务流程。
MapStruct 转换
MapStruct 适合长期维护的对象转换。它在编译期生成实现类,字段缺失或类型不匹配更容易在编译阶段暴露。Spring Boot 项目中建议将 componentModel 设置为 spring,让转换器交给 Spring 容器管理。
文件位置:src/main/java/io/github/atengk/module/system/user/convert/UserConvert.java
package io.github.atengk.module.system.user.convert;
import cn.hutool.core.util.DesensitizedUtil;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.enums.UserStatusEnum;
import io.github.atengk.module.system.user.vo.UserDetailVO;
import io.github.atengk.module.system.user.vo.UserPageVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import java.util.List;
/**
* 用户对象转换器
*
* @author Ateng
* @since 2026-05-05
*/
@Mapper(componentModel = "spring")
public interface UserConvert {
/**
* 新增参数转实体
*
* @param dto 用户新增参数
* @return 用户实体
*/
UserEntity toEntity(UserAddDTO dto);
/**
* 修改参数转实体
*
* @param dto 用户修改参数
* @return 用户实体
*/
UserEntity toEntity(UserUpdateDTO dto);
/**
* 将修改参数合并到已有实体
*
* @param dto 用户修改参数
* @param entity 用户实体
*/
void updateEntity(UserUpdateDTO dto, @MappingTarget UserEntity entity);
/**
* 实体转详情返回对象
*
* @param entity 用户实体
* @return 用户详情返回对象
*/
@Mapping(target = "phone", source = "phone", qualifiedByName = "desensitizePhone")
@Mapping(target = "status", expression = "java(toStatusCode(entity.getStatus()))")
@Mapping(target = "statusName", expression = "java(toStatusName(entity.getStatus()))")
UserDetailVO toDetailVO(UserEntity entity);
/**
* 实体转分页行返回对象
*
* @param entity 用户实体
* @return 用户分页行返回对象
*/
@Mapping(target = "phone", source = "phone", qualifiedByName = "desensitizePhone")
@Mapping(target = "status", expression = "java(toStatusCode(entity.getStatus()))")
@Mapping(target = "statusName", expression = "java(toStatusName(entity.getStatus()))")
UserPageVO toPageVO(UserEntity entity);
/**
* 实体列表转分页行返回对象列表
*
* @param records 用户实体列表
* @return 用户分页行返回对象列表
*/
List<UserPageVO> toPageVOList(List<UserEntity> records);
/**
* 手机号脱敏
*
* @param phone 手机号
* @return 脱敏手机号
*/
@Named("desensitizePhone")
default String desensitizePhone(String phone) {
return DesensitizedUtil.mobilePhone(phone);
}
/**
* 获取状态编码
*
* @param status 用户状态
* @return 状态编码
*/
default Integer toStatusCode(UserStatusEnum status) {
return status == null ? null : status.getCode();
}
/**
* 获取状态名称
*
* @param status 用户状态
* @return 状态名称
*/
default String toStatusName(UserStatusEnum status) {
return status == null ? null : status.getName();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
Service 中使用 MapStruct 转换的示例如下。
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserServiceImpl.java
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.system.user.convert.UserConvert;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.service.UserService;
import io.github.atengk.module.system.user.vo.UserDetailVO;
import io.github.atengk.module.system.user.vo.UserPageVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 用户服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService {
private final UserConvert userConvert;
/**
* 新增用户
*
* @param dto 用户新增参数
* @return 用户 ID
*/
@Override
public Long addUser(UserAddDTO dto) {
UserEntity entity = userConvert.toEntity(dto);
this.save(entity);
log.info("新增用户成功,用户ID:{},用户名:{}", entity.getId(), entity.getUsername());
return entity.getId();
}
/**
* 修改用户
*
* @param dto 用户修改参数
*/
@Override
public void updateUser(UserUpdateDTO dto) {
UserEntity entity = this.getById(dto.getId());
if (entity == null) {
throw new IllegalArgumentException("用户不存在");
}
userConvert.updateEntity(dto, entity);
this.updateById(entity);
log.info("修改用户成功,用户ID:{}", dto.getId());
}
/**
* 查询用户详情
*
* @param id 用户 ID
* @return 用户详情
*/
@Override
public UserDetailVO getUserDetail(Long id) {
UserEntity entity = this.getById(id);
if (entity == null) {
throw new IllegalArgumentException("用户不存在");
}
return userConvert.toDetailVO(entity);
}
/**
* 分页查询用户
*
* @param query 用户分页查询参数
* @return 用户分页结果
*/
@Override
public PageResult<UserPageVO> pageUser(UserPageQuery query) {
Page<UserEntity> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserEntity> result = this.lambdaQuery()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.like(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone())
.eq(query.getStatus() != null, UserEntity::getStatus, query.getStatus())
.ge(query.getStartTime() != null, UserEntity::getCreateTime, query.getStartTime())
.le(query.getEndTime() != null, UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime)
.page(page);
List<UserPageVO> records = CollUtil.isEmpty(result.getRecords())
? List.of()
: userConvert.toPageVOList(result.getRecords());
return PageResult.of(query.getPageNum(), query.getPageSize(), result.getTotal(), records);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
MapStruct 适合转换逻辑稳定、字段明确、需要长期维护的模块。对于空值更新,MapStruct 默认会将 DTO 中的 null 映射到目标对象;如果局部更新需要忽略空值,应在转换器中增加 nullValuePropertyMappingStrategy = IGNORE 等配置,或者单独定义局部更新转换方法。
BeanUtil 转换
Hutool BeanUtil 适合简单对象复制、临时转换、动态 Map 转对象、字段基本一致且不需要复杂映射的场景。它上手快,但缺少 MapStruct 那种编译期字段检查,因此不建议作为大型项目的主转换方案。
简单 DTO 转 Entity 示例:
UserEntity entity = BeanUtil.copyProperties(dto, UserEntity.class);对象列表转换示例:
List<UserPageVO> voList = BeanUtil.copyToList(entityList, UserPageVO.class);动态 Map 转对象示例:
UserAddDTO dto = BeanUtil.toBean(paramMap, UserAddDTO.class);在 Service 中使用 Hutool BeanUtil 的完整示例如下。
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserSimpleServiceImpl.java
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import io.github.atengk.module.system.user.vo.UserPageVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 用户简单转换示例服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class UserSimpleServiceImpl extends ServiceImpl<UserMapper, UserEntity> {
/**
* 使用 Hutool BeanUtil 新增用户
*
* @param dto 用户新增参数
* @return 用户 ID
*/
public Long addUserByBeanUtil(UserAddDTO dto) {
UserEntity entity = BeanUtil.copyProperties(dto, UserEntity.class);
this.save(entity);
log.info("使用BeanUtil新增用户成功,用户ID:{}", entity.getId());
return entity.getId();
}
/**
* 使用 Hutool BeanUtil 转换分页列表
*
* @param entityList 用户实体列表
* @return 用户分页行返回对象列表
*/
public List<UserPageVO> convertPageListByBeanUtil(List<UserEntity> entityList) {
if (CollUtil.isEmpty(entityList)) {
return List.of();
}
return BeanUtil.copyToList(entityList, UserPageVO.class);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
BeanUtil 使用建议如下:
| 场景 | 是否建议 |
|---|---|
| 字段完全一致的简单复制 | 可以使用 |
| 临时内部对象转换 | 可以使用 |
| Map 转 DTO | 可以使用 |
| 枚举编码和名称转换 | 不建议单独依赖 BeanUtil |
| 脱敏转换 | 不建议单独依赖 BeanUtil |
| 多对象聚合转换 | 不建议 |
| 核心接口返回转换 | 更推荐 MapStruct |
综合建议是:核心业务模块使用 MapStruct 保证可维护性;简单工具场景使用 Hutool BeanUtil 提高开发效率。对于涉及脱敏、枚举回显、金额格式、字典回显、关联名称填充的转换,不要只做字段复制,应明确写出转换规则。
Mapper 层开发
Mapper 层负责数据库访问,不承载业务规则。MyBatis-Plus 的 BaseMapper 已经封装了常见 CRUD 方法,包括 insert、deleteById、deleteBatchIds、updateById、selectById、selectList、selectPage、selectCount 等,因此普通单表操作不需要重复编写 SQL。复杂查询、多表关联、统计报表、特殊映射和批量 SQL 仍建议使用 MyBatis XML。MyBatis-Plus 官方也明确说明,BaseMapper 是通用 Mapper 接口,泛型 T 表示实体对象,Wrapper 表示条件构造器。(MyBatis-Plus)
BaseMapper 使用
BaseMapper 是 Mapper 层最基础的能力。业务 Mapper 只需要继承 BaseMapper<Entity>,即可获得基础增删改查能力。普通项目中,Mapper 接口应保持简洁,不要把业务判断写进 Mapper 层。
文件位置:src/main/java/io/github/atengk/module/system/user/mapper/UserMapper.java
package io.github.atengk.module.system.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.atengk.module.system.user.entity.UserEntity;
/**
* 用户 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserMapper extends BaseMapper<UserEntity> {
}2
3
4
5
6
7
8
9
10
11
12
13
Service 中可以直接调用 BaseMapper 方法,但更推荐通过 MyBatis-Plus 的 ServiceImpl 使用基础 CRUD。Mapper 层主要负责 SQL,Service 层负责业务规则和事务边界。
常用 BaseMapper 方法如下:
| 方法 | 用途 |
|---|---|
insert(entity) | 新增一条记录 |
deleteById(id) | 根据主键删除 |
deleteBatchIds(ids) | 根据主键批量删除 |
updateById(entity) | 根据主键修改 |
selectById(id) | 根据主键查询 |
selectList(wrapper) | 根据条件查询列表 |
selectOne(wrapper) | 根据条件查询单条 |
selectPage(page, wrapper) | 分页查询 |
selectCount(wrapper) | 查询数量 |
这段代码演示在 Service 中使用 BaseMapper 完成基础查询和统计。
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserMapperDemoService.java
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 用户 Mapper 基础用法示例服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserMapperDemoService {
private final UserMapper userMapper;
/**
* 根据用户名查询用户列表
*
* @param username 用户名
* @return 用户列表
*/
public List<UserEntity> listByUsername(String username) {
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.like(StrUtil.isNotBlank(username), UserEntity::getUsername, username)
.orderByDesc(UserEntity::getCreateTime);
List<UserEntity> users = userMapper.selectList(wrapper);
log.info("根据用户名查询用户列表完成,用户名:{},数量:{}", username, users.size());
return users;
}
/**
* 判断手机号是否存在
*
* @param phone 手机号
* @return 是否存在
*/
public boolean existsByPhone(String phone) {
Long count = userMapper.selectCount(new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getPhone, phone));
return count != null && count > 0;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
BaseMapper 适合单表基础操作。多表 JOIN、复杂统计、窗口函数、递归查询、复杂动态条件等场景,不建议强行使用 Wrapper 表达,应该使用 XML 自定义 SQL。
自定义 Mapper 方法
当 BaseMapper 无法满足业务查询时,可以在 Mapper 接口中定义自定义方法,并在 XML 中实现。自定义 Mapper 方法应保持数据库访问语义,不写业务规则。
自定义方法适合以下场景:
| 场景 | 示例 |
|---|---|
| 多表分页 | 用户列表同时返回部门名称、角色名称 |
| 复杂筛选 | 多条件组合、子查询、权限过滤 |
| 聚合统计 | 按状态统计、按日期统计 |
| 批量写入 | 特殊批量插入、批量更新 |
| 复杂结果映射 | 一对多、一对一、嵌套对象 |
| 数据报表 | 分组、汇总、窗口函数 |
Mapper 接口示例:
文件位置:src/main/java/io/github/atengk/module/system/user/mapper/UserMapper.java
package io.github.atengk.module.system.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.vo.UserPageVO;
import io.github.atengk.module.system.user.vo.UserStatisticsVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 用户 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserMapper extends BaseMapper<UserEntity> {
/**
* 分页查询用户列表
*
* @param page 分页对象
* @param query 查询参数
* @return 用户分页结果
*/
Page<UserPageVO> selectUserPage(Page<UserPageVO> page, @Param("query") UserPageQuery query);
/**
* 查询用户统计信息
*
* @param tenantId 租户 ID
* @return 用户统计信息
*/
UserStatisticsVO selectUserStatistics(@Param("tenantId") Long tenantId);
/**
* 根据用户 ID 查询角色名称列表
*
* @param userId 用户 ID
* @return 角色名称列表
*/
List<String> selectRoleNamesByUserId(@Param("userId") Long userId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
自定义 Mapper 方法的参数建议显式使用 @Param。这样 XML 中引用参数时更清晰,也能避免多参数场景下出现 param1、param2 可读性差的问题。
XML 自定义 SQL
XML 自定义 SQL 适合复杂查询和需要长期维护的 SQL。MyBatis 官方文档说明,Mapper XML 的核心元素包括 resultMap、sql、insert、update、delete、select 等;resultMap 用于描述结果集到对象的映射,sql 用于复用 SQL 片段。(MyBatis)
文件位置:src/main/resources/mapper/system/UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- namespace 必须与 Mapper 接口全限定名一致 -->
<mapper namespace="io.github.atengk.module.system.user.mapper.UserMapper">
<!-- 用户分页查询字段,避免 SELECT * -->
<sql id="UserPageColumns">
u.id,
u.username,
u.nickname,
u.phone,
u.status,
u.create_time,
d.dept_name
</sql>
<!-- 用户分页查询 -->
<select id="selectUserPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
<include refid="UserPageColumns"/>
FROM sys_user u
LEFT JOIN sys_dept d ON d.id = u.dept_id AND d.deleted = 0
<where>
u.deleted = 0
<if test="query.tenantId != null">
AND u.tenant_id = #{query.tenantId}
</if>
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.phone != null and query.phone != ''">
AND u.phone = #{query.phone}
</if>
<if test="query.status != null">
AND u.status = #{query.status}
</if>
<if test="query.startTime != null">
AND u.create_time >= #{query.startTime}
</if>
<if test="query.endTime != null">
AND u.create_time <= #{query.endTime}
</if>
</where>
ORDER BY u.create_time DESC
</select>
<!-- 用户统计信息 -->
<select id="selectUserStatistics" resultType="io.github.atengk.module.system.user.vo.UserStatisticsVO">
SELECT
COUNT(1) AS total_count,
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS enabled_count,
SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) AS disabled_count
FROM sys_user
WHERE deleted = 0
AND tenant_id = #{tenantId}
</select>
<!-- 查询用户角色名称列表 -->
<select id="selectRoleNamesByUserId" resultType="java.lang.String">
SELECT
r.role_name
FROM sys_user_role ur
INNER JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0
WHERE ur.user_id = #{userId}
AND ur.deleted = 0
ORDER BY r.sort_order ASC, r.create_time DESC
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
XML SQL 编写建议:
| 规范项 | 建议 |
|---|---|
namespace | 必须与 Mapper 接口全限定名一致 |
id | 必须与 Mapper 方法名一致 |
| 查询字段 | 避免 SELECT * |
| SQL 片段 | 通用字段使用 <sql> 和 <include> 复用 |
| 参数 | 使用 #{},不要直接使用 ${} |
| 动态条件 | 使用 <where>、<if>、<foreach>、<trim> |
| 排序字段 | 前端传入排序字段时必须做白名单映射 |
| 复杂结果 | 使用 resultMap,不要硬塞到 resultType |
分页方法中,XML 不需要手写 LIMIT,由 MyBatis-Plus 分页插件处理。前提是项目已正确配置 PaginationInnerInterceptor。
注解 SQL
注解 SQL 适合非常简单、稳定、短小的 SQL,例如根据编码查询 ID、查询某个字段、更新单个状态等。MyBatis 官方 Java API 文档说明,@Select、@Insert、@Update、@Delete 分别表示要执行的 SQL;它们可以接收字符串数组,MyBatis 会用空格连接这些字符串。(MyBatis)
注解 SQL 示例:
文件位置:src/main/java/io/github/atengk/module/system/user/mapper/UserMapper.java
package io.github.atengk.module.system.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.atengk.module.system.user.entity.UserEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 用户 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserMapper extends BaseMapper<UserEntity> {
/**
* 根据用户名查询用户 ID
*
* @param username 用户名
* @return 用户 ID
*/
@Select("""
SELECT id
FROM sys_user
WHERE username = #{username}
AND deleted = 0
LIMIT 1
""")
Long selectIdByUsername(@Param("username") String username);
/**
* 修改用户状态
*
* @param id 用户 ID
* @param status 状态
* @return 影响行数
*/
@Update("""
UPDATE sys_user
SET status = #{status},
update_time = NOW()
WHERE id = #{id}
AND deleted = 0
""")
int updateStatusById(@Param("id") Long id, @Param("status") Integer status);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
注解 SQL 不建议用于复杂动态 SQL、多表关联、长 SQL、批量 SQL 和复杂结果映射。原因是注解 SQL 不利于格式化、复用、调试和长期维护。只要 SQL 超过十几行,优先放到 XML 中。
批量操作 Mapper
批量操作应优先使用 MyBatis-Plus Service 层的 saveBatch、saveOrUpdateBatch、updateBatchById 等方法。MyBatis-Plus 官方 CRUD 文档中,通用 Service 已经提供批量保存、批量修改、批量保存或修改等能力。(MyBatis-Plus)
如果业务确实需要 Mapper 层批量插入,例如导入数据、批量初始化、性能优化,可以使用 XML 的 foreach 拼接多行插入。
Mapper 方法:
/**
* 批量插入用户
*
* @param users 用户列表
* @return 影响行数
*/
int insertUserBatch(@Param("users") List<UserEntity> users);2
3
4
5
6
7
XML 示例:
<!-- 批量插入用户 -->
<insert id="insertUserBatch">
INSERT INTO sys_user (
id,
tenant_id,
username,
nickname,
phone,
status,
create_time,
update_time,
deleted,
version
)
VALUES
<foreach collection="users" item="item" separator=",">
(
#{item.id},
#{item.tenantId},
#{item.username},
#{item.nickname},
#{item.phone},
#{item.status},
#{item.createTime},
#{item.updateTime},
#{item.deleted},
#{item.version}
)
</foreach>
</insert>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
批量更新可以使用 foreach 拼接多条 UPDATE,但需要确认 JDBC URL 是否允许多语句执行。多数情况下,更推荐使用 updateBatchById 或按业务改写为单条条件更新。
批量操作建议如下:
| 场景 | 建议 |
|---|---|
| 普通批量新增 | 使用 saveBatch |
| 普通批量修改 | 使用 updateBatchById |
| 特殊批量插入 | XML foreach 多行插入 |
| 大批量导入 | 分批处理,每批 500 到 2000 条,根据压测调整 |
| 批量更新不同值 | 谨慎使用多 SQL 或 CASE WHEN |
| 批量删除 | 优先使用 removeBatchByIds 或 deleteBatchIds |
| 超大批量 | 结合临时表、异步任务、消息队列或数据库导入工具 |
批量操作必须控制批次大小,不要一次性把几十万条数据放入内存,也不要在一个超大事务里处理全部数据。
复杂查询 Mapper
复杂查询 Mapper 通常返回 VO 或自定义结果对象,而不是返回 Entity。Entity 应尽量保持与单表结构一致;多表字段、统计字段、回显字段应放到 VO 中。
复杂查询示例:分页查询用户,同时返回部门名称和角色数量。
VO 示例:
文件位置:src/main/java/io/github/atengk/module/system/user/vo/UserPageVO.java
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户分页返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserPageVO {
/**
* 用户 ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 手机号
*/
private String phone;
/**
* 状态
*/
private Integer status;
/**
* 部门名称
*/
private String deptName;
/**
* 角色数量
*/
private Integer roleCount;
/**
* 创建时间
*/
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
XML 示例:
<!-- 复杂用户分页查询 -->
<select id="selectUserComplexPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
u.id,
u.username,
u.nickname,
u.phone,
u.status,
u.create_time,
d.dept_name,
COUNT(ur.role_id) AS role_count
FROM sys_user u
LEFT JOIN sys_dept d ON d.id = u.dept_id AND d.deleted = 0
LEFT JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
<where>
u.deleted = 0
<if test="query.tenantId != null">
AND u.tenant_id = #{query.tenantId}
</if>
<if test="query.keyword != null and query.keyword != ''">
AND (
u.username LIKE CONCAT('%', #{query.keyword}, '%')
OR u.nickname LIKE CONCAT('%', #{query.keyword}, '%')
OR u.phone LIKE CONCAT('%', #{query.keyword}, '%')
)
</if>
</where>
GROUP BY
u.id,
u.username,
u.nickname,
u.phone,
u.status,
u.create_time,
d.dept_name
ORDER BY u.create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
复杂查询建议:
| 规范项 | 建议 |
|---|---|
| 返回对象 | 使用 VO 或专用 Result 对象 |
| 字段别名 | SQL 别名与 Java 属性保持一致 |
| 聚合查询 | 明确 GROUP BY 字段 |
| 多表 JOIN | 注意逻辑删除、租户条件、数据权限 |
| 分页查询 | 关注 COUNT SQL 性能 |
| 大字段 | 列表查询不要返回大字段 |
| 性能验证 | 使用执行计划和慢 SQL 日志验证 |
复杂查询不要把所有字段都堆到 Entity 中,更不要让 Entity 包含大量 @TableField(exist = false) 字段来适配查询结果。
统计查询 Mapper
统计查询通常返回专用统计 VO。统计查询不建议返回 Map<String, Object>,除非字段确实动态变化。固定统计项应定义明确类型,便于接口文档、前端联调和后续维护。
统计 VO 示例:
文件位置:src/main/java/io/github/atengk/module/system/user/vo/UserStatisticsVO.java
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
/**
* 用户统计返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserStatisticsVO {
/**
* 用户总数
*/
private Long totalCount;
/**
* 启用用户数
*/
private Long enabledCount;
/**
* 禁用用户数
*/
private Long disabledCount;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
统计 SQL 示例:
<!-- 用户状态统计 -->
<select id="selectUserStatistics" resultType="io.github.atengk.module.system.user.vo.UserStatisticsVO">
SELECT
COUNT(1) AS total_count,
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS enabled_count,
SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) AS disabled_count
FROM sys_user
WHERE deleted = 0
AND tenant_id = #{tenantId}
</select>2
3
4
5
6
7
8
9
10
按日期统计示例:
<!-- 按日期统计新增用户数量 -->
<select id="selectDailyUserCount" resultType="io.github.atengk.module.system.user.vo.DailyUserCountVO">
SELECT
DATE(create_time) AS stat_date,
COUNT(1) AS user_count
FROM sys_user
WHERE deleted = 0
AND tenant_id = #{tenantId}
AND create_time >= #{startTime}
AND create_time < #{endTime}
GROUP BY DATE(create_time)
ORDER BY stat_date ASC
</select>2
3
4
5
6
7
8
9
10
11
12
13
统计查询注意事项:
| 场景 | 建议 |
|---|---|
| 实时统计 | 数据量小或索引充分时使用 |
| 大表统计 | 考虑汇总表、定时任务、异步统计 |
| 报表查询 | 不建议直接压业务主库 |
| 多维统计 | 明确索引和分组字段 |
| 分页统计 | 避免复杂 JOIN 后再 COUNT |
| 高并发看板 | 建议缓存统计结果 |
统计 SQL 的性能问题通常比普通查询更明显。生产环境中,统计查询必须结合执行计划和数据量评估。
动态 SQL
动态 SQL 用于根据参数决定是否拼接 SQL 条件。XML 中常用 <where>、<if>、<foreach>、<trim>、<choose> 等标签。MyBatis XML 本身就是为编写映射语句和动态 SQL 设计的;其核心元素包括 select、insert、update、delete、sql、resultMap 等。(MyBatis)
动态查询示例:
<!-- 动态条件查询用户列表 -->
<select id="selectUserListByCondition" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
id,
username,
nickname,
phone,
status,
create_time
FROM sys_user
<where>
deleted = 0
<if test="query.tenantId != null">
AND tenant_id = #{query.tenantId}
</if>
<if test="query.username != null and query.username != ''">
AND username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.status != null">
AND status = #{query.status}
</if>
<if test="query.startTime != null">
AND create_time >= #{query.startTime}
</if>
<if test="query.endTime != null">
AND create_time <= #{query.endTime}
</if>
</where>
ORDER BY create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
IN 查询示例:
<!-- 根据用户 ID 集合查询用户 -->
<select id="selectUsersByIds" resultType="io.github.atengk.module.system.user.entity.UserEntity">
SELECT
id,
username,
nickname,
phone,
status,
create_time
FROM sys_user
WHERE deleted = 0
AND id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
动态更新示例:
<!-- 动态更新用户字段 -->
<update id="updateUserSelective">
UPDATE sys_user
<set>
<if test="entity.nickname != null">
nickname = #{entity.nickname},
</if>
<if test="entity.phone != null">
phone = #{entity.phone},
</if>
<if test="entity.email != null">
email = #{entity.email},
</if>
update_time = NOW()
</set>
WHERE id = #{entity.id}
AND deleted = 0
</update>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
动态 SQL 安全建议:
| 风险点 | 建议 |
|---|---|
${} | 禁止直接接收前端参数拼接 |
| 排序字段 | 使用白名单映射 |
| 表名动态 | 使用严格枚举或上下文控制 |
| 字段名动态 | 禁止直接透传 |
LIKE | 控制长度,避免无索引大范围扫描 |
IN | 控制集合大小,避免超长 SQL |
| 空条件更新 | 更新 SQL 必须有明确 WHERE 条件 |
动态 SQL 的核心原则是:值使用 #{},结构类内容必须白名单化。
多表关联查询
多表关联查询适合放在 XML 中编写。普通多表查询可以直接返回 VO;一对多嵌套对象、集合映射等复杂结果建议使用 resultMap。MyBatis Dynamic SQL 文档也指出,JOIN 场景通常需要 XML Mapper 定义 resultMap,尤其是集合映射这类注解难以表达的结果结构。(MyBatis)
普通多表 VO 查询示例:
<!-- 查询用户详情,包含部门名称 -->
<select id="selectUserDetail" resultType="io.github.atengk.module.system.user.vo.UserDetailVO">
SELECT
u.id,
u.username,
u.nickname,
u.phone,
u.email,
u.status,
u.create_time,
u.update_time,
d.dept_name
FROM sys_user u
LEFT JOIN sys_dept d ON d.id = u.dept_id AND d.deleted = 0
WHERE u.id = #{id}
AND u.deleted = 0
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
如果需要返回用户及角色列表,可以拆成两次查询,避免一条 SQL 返回重复用户行后还要复杂去重:
/**
* 查询用户详情
*
* @param id 用户 ID
* @return 用户详情
*/
UserDetailVO selectUserDetail(@Param("id") Long id);
/**
* 查询用户角色名称列表
*
* @param userId 用户 ID
* @return 角色名称列表
*/
List<String> selectRoleNamesByUserId(@Param("userId") Long userId);2
3
4
5
6
7
8
9
10
11
12
13
14
15
多表关联查询建议:
| 规范项 | 建议 |
|---|---|
| 简单一对一 | 直接 JOIN 返回 VO |
| 一对多集合 | 优先拆分查询,必要时用 resultMap |
| 分页 JOIN | 关注 COUNT SQL 和重复行问题 |
| 关联条件 | 关联表也要加逻辑删除条件 |
| 多租户 | 主表和子表租户条件要一致 |
| 数据权限 | 明确权限条件注入位置 |
| 字段别名 | SQL 别名与 VO 属性保持一致 |
多表分页时要特别注意一对多 JOIN 导致分页记录重复的问题。必要时先分页主表 ID,再二次查询关联数据。
ResultMap 映射
resultMap 用于复杂结果集映射。MyBatis 官方文档说明,resultMap 是 MyBatis 中最强大的特性之一,可以解决很多复杂映射问题;同时 resultType 和 resultMap 应二选一,不能同时使用。(MyBatis)
一对一映射示例:
<!-- 用户详情 ResultMap -->
<resultMap id="UserDetailResultMap" type="io.github.atengk.module.system.user.vo.UserDetailVO">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="nickname" property="nickname"/>
<result column="phone" property="phone"/>
<result column="email" property="email"/>
<result column="status" property="status"/>
<result column="dept_name" property="deptName"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
</resultMap>
<select id="selectUserDetailByResultMap" resultMap="UserDetailResultMap">
SELECT
u.id,
u.username,
u.nickname,
u.phone,
u.email,
u.status,
u.create_time,
u.update_time,
d.dept_name
FROM sys_user u
LEFT JOIN sys_dept d ON d.id = u.dept_id AND d.deleted = 0
WHERE u.id = #{id}
AND u.deleted = 0
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
一对多集合映射示例:
<!-- 用户及角色集合 ResultMap -->
<resultMap id="UserRoleResultMap" type="io.github.atengk.module.system.user.vo.UserRoleDetailVO">
<id column="user_id" property="id"/>
<result column="username" property="username"/>
<result column="nickname" property="nickname"/>
<collection property="roles" ofType="io.github.atengk.module.system.role.vo.RoleSimpleVO">
<id column="role_id" property="id"/>
<result column="role_name" property="roleName"/>
<result column="role_code" property="roleCode"/>
</collection>
</resultMap>
<select id="selectUserWithRoles" resultMap="UserRoleResultMap">
SELECT
u.id AS user_id,
u.username,
u.nickname,
r.id AS role_id,
r.role_name,
r.role_code
FROM sys_user u
LEFT JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
LEFT JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0
WHERE u.id = #{id}
AND u.deleted = 0
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
resultMap 使用建议:
| 场景 | 建议 |
|---|---|
| 字段名与属性名一致 | 可用 resultType |
| 字段别名能匹配属性 | 可用 resultType |
| 字段映射复杂 | 使用 resultMap |
| 一对一嵌套对象 | 使用 association |
| 一对多集合 | 使用 collection |
| JSON 或特殊类型 | 结合 TypeHandler |
| 复杂映射 | 从简单 resultMap 逐步扩展并测试 |
复杂 resultMap 不要一次性写得过大。应先验证基础字段,再逐步加入嵌套对象和集合字段。
TypeHandler 使用
TypeHandler 用于 Java 类型和 JDBC 类型之间的转换。MyBatis-Plus 官方 TypeHandler 文档说明,TypeHandler 是 JavaType 和 JdbcType 之间的桥梁,用于 SQL 执行时设置 PreparedStatement 参数,或从 ResultSet、CallableStatement 中取值;MyBatis-Plus 也提供了内置 JSON TypeHandler,例如 JacksonTypeHandler、Fastjson2TypeHandler、GsonTypeHandler 等。(MyBatis-Plus)
JSON 字段映射示例:
文件位置:src/main/java/io/github/atengk/module/system/user/model/UserExtraInfo.java
package io.github.atengk.module.system.user.model;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 用户扩展信息
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserExtraInfo implements Serializable {
/**
* 最近登录 IP
*/
private String lastLoginIp;
/**
* 最近登录时间
*/
private LocalDateTime lastLoginTime;
/**
* 注册来源
*/
private String registerSource;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
实体字段配置:
package io.github.atengk.module.system.user.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import io.github.atengk.common.entity.BaseEntity;
import io.github.atengk.module.system.user.model.UserExtraInfo;
import lombok.Getter;
import lombok.Setter;
/**
* 用户实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName(value = "sys_user", autoResultMap = true)
public class UserEntity extends BaseEntity {
/**
* 用户名
*/
private String username;
/**
* 扩展信息
*/
@TableField(value = "extra_info", typeHandler = JacksonTypeHandler.class)
private UserExtraInfo extraInfo;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
XML 中使用 TypeHandler 示例:
<resultMap id="UserEntityResultMap" type="io.github.atengk.module.system.user.entity.UserEntity">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="extra_info"
property="extraInfo"
typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
</resultMap>2
3
4
5
6
7
TypeHandler 使用建议:
| 场景 | 建议 |
|---|---|
| JSON 字段 | 使用 MyBatis-Plus 内置 JSON TypeHandler |
| 枚举字段 | 优先使用 @EnumValue 或统一枚举处理 |
| 加密字段 | 可自定义 TypeHandler,但要评估查询和索引 |
| 特殊数据库类型 | 可自定义 TypeHandler |
| 高频查询字段 | 不建议放 JSON 或复杂 TypeHandler |
| XML 映射 | 复杂映射中显式指定 TypeHandler |
JSON 字段需要注意:适合保存扩展信息,不适合保存高频查询、排序、统计和唯一约束字段。
Mapper 单元测试
Mapper 单元测试用于验证 SQL 是否正确、字段映射是否正确、动态条件是否生效、分页是否正常、逻辑删除是否过滤、TypeHandler 是否正常转换。MyBatis 官方文档也建议对 resultMap 这类复杂映射逐步构建并进行单元测试,因为复杂映射一次写完很容易出错。(MyBatis)
测试建议优先使用独立测试库,避免连接开发库或生产库。可以使用 Testcontainers、Docker MySQL、H2 或专用测试数据库。涉及 MySQL 方言、JSON 字段、分页插件、函数、索引行为时,优先使用真实 MySQL 测试。
测试依赖示例:
<!-- Spring Boot 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- MyBatis-Plus 测试仍然建议连接真实测试数据库或 Testcontainers 数据库 -->2
3
4
5
6
7
8
测试配置示例:
文件位置:src/test/resources/application-test.yml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/mybatis_plus_demo_test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
global-config:
banner: false
db-config:
id-type: ASSIGN_ID
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
configuration:
map-underscore-to-camel-case: true
logging:
level:
io.github.atengk.**.mapper: debug2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Mapper 测试示例:
文件位置:src/test/java/io/github/atengk/module/system/user/mapper/UserMapperTest.java
package io.github.atengk.module.system.user.mapper;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.vo.UserPageVO;
import io.github.atengk.module.system.user.vo.UserStatisticsVO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
/**
* 用户 Mapper 测试
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@SpringBootTest
@ActiveProfiles("test")
@MapperScan("io.github.atengk.**.mapper")
class UserMapperTest {
@Autowired
private UserMapper userMapper;
/**
* 测试用户分页查询
*/
@Test
void testSelectUserPage() {
UserPageQuery query = new UserPageQuery();
query.setPageNum(1L);
query.setPageSize(10L);
query.setUsername("admin");
Page<UserPageVO> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserPageVO> result = userMapper.selectUserPage(page, query);
Assertions.assertNotNull(result);
Assertions.assertNotNull(result.getRecords());
log.info("用户分页查询测试完成,总数:{},当前页数量:{}",
result.getTotal(), result.getRecords().size());
}
/**
* 测试用户统计查询
*/
@Test
void testSelectUserStatistics() {
UserStatisticsVO statistics = userMapper.selectUserStatistics(0L);
Assertions.assertNotNull(statistics);
Assertions.assertNotNull(statistics.getTotalCount());
log.info("用户统计查询测试完成,总数:{},启用数:{},禁用数:{}",
statistics.getTotalCount(),
statistics.getEnabledCount(),
statistics.getDisabledCount());
}
/**
* 测试查询用户角色名称
*/
@Test
void testSelectRoleNamesByUserId() {
List<String> roleNames = userMapper.selectRoleNamesByUserId(1L);
Assertions.assertNotNull(roleNames);
log.info("用户角色名称查询测试完成,是否为空:{},数量:{}",
CollUtil.isEmpty(roleNames), roleNames.size());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
Mapper 测试重点如下:
| 测试项 | 验证内容 |
|---|---|
| XML 是否加载 | 启动时不报 Invalid bound statement |
| 参数绑定 | @Param 名称与 XML 引用一致 |
| 字段映射 | SQL 别名与 VO 属性一致 |
| 分页查询 | 总数、分页数据是否正常 |
| 逻辑删除 | 已删除数据是否被过滤 |
| 动态条件 | 不同条件组合是否正确 |
| ResultMap | 嵌套对象和集合是否正确 |
| TypeHandler | JSON、枚举、特殊字段是否正常转换 |
| SQL 性能 | 可配合执行计划和慢 SQL 日志检查 |
Mapper 测试不应只验证“能运行”,还应覆盖空条件、单条件、多条件、无数据、边界分页、逻辑删除、非法参数等场景。对于复杂 SQL,建议每次修改 XML 后都执行对应 Mapper 测试,避免接口层才发现 SQL 映射错误。
Service 层开发
Service 层是业务规则、事务边界、对象转换、数据校验和数据访问编排的核心位置。Controller 不应直接操作 Mapper,Mapper 不应承载业务判断。MyBatis-Plus 提供了 IService 和 ServiceImpl,可以减少基础 CRUD 代码,但业务系统仍然需要在 Service 层处理唯一性校验、状态流转、幂等控制、事务控制、批量处理和异常转换。
IService 使用
IService<T> 是 MyBatis-Plus 提供的通用 Service 接口,封装了常见的新增、修改、删除、查询、分页、批量保存等方法。业务 Service 接口可以继承 IService<Entity>,在继承通用能力的同时声明业务方法。
文件位置:src/main/java/io/github/atengk/module/system/user/service/UserService.java
package io.github.atengk.module.system.user.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.vo.UserDetailVO;
import io.github.atengk.module.system.user.vo.UserPageVO;
import java.util.List;
/**
* 用户服务接口
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserService extends IService<UserEntity> {
/**
* 新增用户
*
* @param dto 用户新增参数
* @return 用户 ID
*/
Long addUser(UserAddDTO dto);
/**
* 修改用户
*
* @param dto 用户修改参数
*/
void updateUser(UserUpdateDTO dto);
/**
* 根据 ID 删除用户
*
* @param id 用户 ID
*/
void deleteUser(Long id);
/**
* 查询用户详情
*
* @param id 用户 ID
* @return 用户详情
*/
UserDetailVO getUserDetail(Long id);
/**
* 查询用户列表
*
* @param query 查询参数
* @return 用户列表
*/
List<UserPageVO> listUser(UserPageQuery query);
/**
* 分页查询用户
*
* @param query 分页查询参数
* @return 用户分页结果
*/
PageResult<UserPageVO> pageUser(UserPageQuery query);
/**
* 批量新增用户
*
* @param dtoList 用户新增参数列表
* @return 用户 ID 列表
*/
List<Long> batchAddUser(List<UserAddDTO> dtoList);
/**
* 批量修改用户
*
* @param dtoList 用户修改参数列表
*/
void batchUpdateUser(List<UserUpdateDTO> dtoList);
/**
* 批量删除用户
*
* @param ids 用户 ID 列表
*/
void batchDeleteUser(List<Long> ids);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
IService 适合暴露通用 Service 能力,但不代表 Controller 可以随意调用所有通用方法。对外接口应优先调用明确的业务方法,例如 addUser、updateUser、pageUser,而不是在 Controller 中直接使用 save、removeById、lambdaQuery。
ServiceImpl 使用
ServiceImpl<M extends BaseMapper<T>, T> 是 MyBatis-Plus 提供的通用 Service 实现基类。业务 Service 实现类继承 ServiceImpl<UserMapper, UserEntity> 后,可以直接使用 save、saveBatch、updateById、removeById、getById、list、page、lambdaQuery、lambdaUpdate 等方法。
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserServiceImpl.java
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.DesensitizedUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.system.user.convert.UserConvert;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.enums.UserStatusEnum;
import io.github.atengk.module.system.user.mapper.UserMapper;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.service.UserService;
import io.github.atengk.module.system.user.vo.UserDetailVO;
import io.github.atengk.module.system.user.vo.UserPageVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* 用户服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService {
private final UserConvert userConvert;
/**
* 新增用户
*
* @param dto 用户新增参数
* @return 用户 ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long addUser(UserAddDTO dto) {
this.checkUsernameUnique(dto.getUsername(), null);
this.checkPhoneUnique(dto.getPhone(), null);
UserEntity entity = userConvert.toEntity(dto);
this.save(entity);
log.info("新增用户成功,用户ID:{},用户名:{}", entity.getId(), entity.getUsername());
return entity.getId();
}
/**
* 修改用户
*
* @param dto 用户修改参数
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void updateUser(UserUpdateDTO dto) {
UserEntity entity = this.getRequiredById(dto.getId());
if (StrUtil.isNotBlank(dto.getPhone())) {
this.checkPhoneUnique(dto.getPhone(), dto.getId());
}
userConvert.updateEntity(dto, entity);
boolean updated = this.updateById(entity);
if (!updated) {
throw new IllegalStateException("用户修改失败,请刷新后重试");
}
log.info("修改用户成功,用户ID:{}", dto.getId());
}
/**
* 根据 ID 删除用户
*
* @param id 用户 ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteUser(Long id) {
UserEntity entity = this.getRequiredById(id);
boolean removed = this.removeById(entity.getId());
if (!removed) {
throw new IllegalStateException("用户删除失败,请刷新后重试");
}
log.info("删除用户成功,用户ID:{}", id);
}
/**
* 查询用户详情
*
* @param id 用户 ID
* @return 用户详情
*/
@Override
public UserDetailVO getUserDetail(Long id) {
UserEntity entity = this.getRequiredById(id);
UserDetailVO detailVO = userConvert.toDetailVO(entity);
detailVO.setPhone(DesensitizedUtil.mobilePhone(detailVO.getPhone()));
return detailVO;
}
/**
* 查询用户列表
*
* @param query 查询参数
* @return 用户列表
*/
@Override
public List<UserPageVO> listUser(UserPageQuery query) {
List<UserEntity> records = this.lambdaQuery()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime)
.list();
if (CollUtil.isEmpty(records)) {
return Collections.emptyList();
}
return userConvert.toPageVOList(records);
}
/**
* 分页查询用户
*
* @param query 分页查询参数
* @return 用户分页结果
*/
@Override
public PageResult<UserPageVO> pageUser(UserPageQuery query) {
Page<UserEntity> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserEntity> result = this.lambdaQuery()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime)
.page(page);
List<UserPageVO> records = CollUtil.isEmpty(result.getRecords())
? Collections.emptyList()
: userConvert.toPageVOList(result.getRecords());
return PageResult.of(query.getPageNum(), query.getPageSize(), result.getTotal(), records);
}
/**
* 批量新增用户
*
* @param dtoList 用户新增参数列表
* @return 用户 ID 列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<Long> batchAddUser(List<UserAddDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return Collections.emptyList();
}
List<UserEntity> entities = dtoList.stream()
.peek(dto -> {
this.checkUsernameUnique(dto.getUsername(), null);
this.checkPhoneUnique(dto.getPhone(), null);
})
.map(userConvert::toEntity)
.toList();
this.saveBatch(entities, 1000);
log.info("批量新增用户成功,数量:{}", entities.size());
return entities.stream().map(UserEntity::getId).toList();
}
/**
* 批量修改用户
*
* @param dtoList 用户修改参数列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void batchUpdateUser(List<UserUpdateDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return;
}
List<UserEntity> entities = dtoList.stream()
.map(dto -> {
UserEntity entity = this.getRequiredById(dto.getId());
if (StrUtil.isNotBlank(dto.getPhone())) {
this.checkPhoneUnique(dto.getPhone(), dto.getId());
}
userConvert.updateEntity(dto, entity);
return entity;
})
.toList();
this.updateBatchById(entities, 1000);
log.info("批量修改用户成功,数量:{}", entities.size());
}
/**
* 批量删除用户
*
* @param ids 用户 ID 列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void batchDeleteUser(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return;
}
boolean removed = this.removeBatchByIds(ids, 1000);
if (!removed) {
throw new IllegalStateException("批量删除用户失败");
}
log.info("批量删除用户成功,数量:{}", ids.size());
}
/**
* 根据 ID 查询用户,不存在时抛出异常
*
* @param id 用户 ID
* @return 用户实体
*/
private UserEntity getRequiredById(Long id) {
UserEntity entity = this.getById(id);
if (entity == null) {
throw new IllegalArgumentException("用户不存在");
}
return entity;
}
/**
* 校验用户名唯一
*
* @param username 用户名
* @param excludeId 排除的用户 ID
*/
private void checkUsernameUnique(String username, Long excludeId) {
if (StrUtil.isBlank(username)) {
return;
}
long count = this.lambdaQuery()
.eq(UserEntity::getUsername, username)
.ne(ObjectUtil.isNotNull(excludeId), UserEntity::getId, excludeId)
.count();
if (count > 0) {
throw new IllegalArgumentException("用户名已存在");
}
}
/**
* 校验手机号唯一
*
* @param phone 手机号
* @param excludeId 排除的用户 ID
*/
private void checkPhoneUnique(String phone, Long excludeId) {
if (StrUtil.isBlank(phone)) {
return;
}
long count = this.lambdaQuery()
.eq(UserEntity::getPhone, phone)
.ne(ObjectUtil.isNotNull(excludeId), UserEntity::getId, excludeId)
.count();
if (count > 0) {
throw new IllegalArgumentException("手机号已存在");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
上述实现中,Service 层负责业务校验、事务控制、对象转换和日志记录;Mapper 层只负责数据库访问;Controller 只负责接收请求和返回响应。这样的分层更利于后续维护和测试。
基础 CRUD 封装
基础 CRUD 封装的目标是统一新增、修改、删除、详情、列表和分页的写法。MyBatis-Plus 已经提供通用方法,但业务项目通常还需要统一异常、日志、校验和转换。
常见 CRUD 方法职责如下:
| 方法 | 职责 |
|---|---|
addUser | 参数转实体、唯一性校验、新增数据、返回 ID |
updateUser | 查询旧数据、业务校验、合并字段、更新数据 |
deleteUser | 查询旧数据、业务限制校验、逻辑删除 |
getUserDetail | 查询实体、转换详情 VO、处理脱敏和回显 |
listUser | 查询列表、转换列表 VO |
pageUser | 分页查询、转换分页行 VO、返回分页包装对象 |
batchAddUser | 分批新增、事务控制、重复校验 |
batchUpdateUser | 批量查询、批量校验、批量更新 |
batchDeleteUser | 参数清洗、批量逻辑删除 |
基础 CRUD 不建议全部做成抽象父类。可以抽取公共分页对象、统一响应对象、基础 Entity、基础 Query,但每个业务模块的校验规则、状态规则和权限规则通常不同,过度抽象会导致父类复杂且难以维护。
新增业务实现
新增业务通常包含参数校验、唯一性校验、默认值处理、对象转换、保存数据和操作日志。基础格式如下:
@Transactional(rollbackFor = Exception.class)
public Long addUser(UserAddDTO dto) {
this.checkUsernameUnique(dto.getUsername(), null);
this.checkPhoneUnique(dto.getPhone(), null);
UserEntity entity = userConvert.toEntity(dto);
this.save(entity);
log.info("新增用户成功,用户ID:{},用户名:{}", entity.getId(), entity.getUsername());
return entity.getId();
}2
3
4
5
6
7
8
9
10
11
新增业务注意事项:
| 检查项 | 说明 |
|---|---|
| 唯一性 | 用户名、手机号、编码、订单号等需要校验 |
| 默认值 | 状态、排序、金额等可以由 Service 设置 |
| 租户字段 | 多租户系统中不能信任前端传入的租户 ID |
| 创建人 | 建议通过自动填充处理 |
| 事务 | 单表新增可不显式加事务,多表新增必须加 |
| 幂等 | 外部请求、导入、支付回调等需要幂等控制 |
数据库唯一索引仍然必须保留。Service 层唯一性校验用于提前给出友好提示,数据库唯一约束用于防止并发下出现脏数据。
修改业务实现
修改业务通常先查询旧数据,再做业务校验,最后合并字段并更新。不要直接把前端传入 DTO 转成 Entity 后调用 updateById,否则可能出现空字段覆盖、非法字段更新、乐观锁失效等问题。
@Transactional(rollbackFor = Exception.class)
public void updateUser(UserUpdateDTO dto) {
UserEntity entity = this.getRequiredById(dto.getId());
if (StrUtil.isNotBlank(dto.getPhone())) {
this.checkPhoneUnique(dto.getPhone(), dto.getId());
}
userConvert.updateEntity(dto, entity);
boolean updated = this.updateById(entity);
if (!updated) {
throw new IllegalStateException("用户修改失败,请刷新后重试");
}
log.info("修改用户成功,用户ID:{}", dto.getId());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
修改业务注意事项:
| 检查项 | 说明 |
|---|---|
| 数据是否存在 | 修改前必须查询原数据 |
| 唯一字段 | 修改用户名、手机号、编码时要排除当前 ID |
| 状态流转 | 状态不能随意跨越,例如已完成订单不能回到待支付 |
| 空字段策略 | 明确空值是忽略还是清空 |
| 乐观锁 | 并发修改敏感数据时建议使用 version |
| 敏感字段 | 密码、余额、权限字段应使用专门接口修改 |
对于局部更新,建议使用 lambdaUpdate 或单独 DTO;对于完整更新,建议 DTO 中字段完整校验。
删除业务实现
删除业务应先判断数据是否存在,再判断是否允许删除。即使使用逻辑删除,也不能绕过业务规则。例如存在下级部门、角色已绑定用户、订单已支付、字典项被引用时,都不应直接删除。
@Transactional(rollbackFor = Exception.class)
public void deleteUser(Long id) {
UserEntity entity = this.getRequiredById(id);
boolean removed = this.removeById(entity.getId());
if (!removed) {
throw new IllegalStateException("用户删除失败,请刷新后重试");
}
log.info("删除用户成功,用户ID:{}", id);
}2
3
4
5
6
7
8
9
10
11
删除业务注意事项:
| 检查项 | 说明 |
|---|---|
| 是否存在 | 不存在时返回明确异常 |
| 是否被引用 | 被角色、订单、配置引用的数据不应直接删 |
| 是否系统内置 | 管理员、默认角色、内置字典通常禁止删除 |
| 删除方式 | 普通业务表推荐逻辑删除 |
| 批量删除 | 每个 ID 都应符合删除条件 |
| 操作日志 | 删除属于高风险操作,应记录日志 |
删除接口不建议直接物理删除核心业务表。物理删除适合临时表、导入中间表、过期缓存表、任务临时结果表等非核心数据。
详情查询实现
详情查询负责返回单条数据完整展示信息。通常需要查询实体、转换 VO、处理脱敏、枚举回显、字典回显和关联信息。
public UserDetailVO getUserDetail(Long id) {
UserEntity entity = this.getRequiredById(id);
UserDetailVO detailVO = userConvert.toDetailVO(entity);
detailVO.setPhone(DesensitizedUtil.mobilePhone(detailVO.getPhone()));
return detailVO;
}2
3
4
5
6
详情查询注意事项:
| 场景 | 建议 |
|---|---|
| 普通字段 | 从 Entity 转 VO |
| 枚举字段 | 返回编码和名称 |
| 字典字段 | 返回原始值和回显名称 |
| 敏感字段 | 根据权限决定是否脱敏 |
| 关联字段 | 可通过 Mapper 自定义 SQL 或额外查询补充 |
| 大字段 | 仅详情接口返回,列表接口不返回 |
| 权限控制 | 查询前或查询后校验数据权限 |
如果详情需要大量关联信息,例如用户角色、部门、岗位、权限等,可以使用自定义 Mapper SQL 查询,也可以先查询主表,再批量查询关联数据后组装 VO。
列表查询实现
列表查询适合返回不分页的小数据集合,例如下拉框、字典项、角色列表、菜单列表等。对于可能超过几百条的数据,应优先使用分页查询。
public List<UserPageVO> listUser(UserPageQuery query) {
List<UserEntity> records = this.lambdaQuery()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.orderByDesc(UserEntity::getCreateTime)
.list();
if (CollUtil.isEmpty(records)) {
return Collections.emptyList();
}
return userConvert.toPageVOList(records);
}2
3
4
5
6
7
8
9
10
11
12
列表查询注意事项:
| 检查项 | 建议 |
|---|---|
| 数据量 | 不确定数据量时使用分页 |
| 排序 | 必须明确排序规则 |
| 字段 | 不返回大字段和敏感字段 |
| 条件 | 空条件列表查询要谨慎 |
| 缓存 | 字典、配置、菜单等可考虑缓存 |
| 权限 | 用户可见范围需要过滤 |
不要给大表提供无分页列表接口。后台管理系统中,即使当前数据量小,也应考虑未来增长。
分页查询实现
分页查询是后台管理系统最常用的查询方式。Service 层负责组装分页对象、拼接条件、执行分页、转换 VO 和返回统一分页结果。
public PageResult<UserPageVO> pageUser(UserPageQuery query) {
Page<UserEntity> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserEntity> result = this.lambdaQuery()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime)
.page(page);
List<UserPageVO> records = CollUtil.isEmpty(result.getRecords())
? Collections.emptyList()
: userConvert.toPageVOList(result.getRecords());
return PageResult.of(query.getPageNum(), query.getPageSize(), result.getTotal(), records);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
分页查询注意事项:
| 检查项 | 建议 |
|---|---|
| 页码 | pageNum >= 1 |
| 条数 | 限制最大 pageSize |
| 排序 | 默认排序必须明确 |
| 大字段 | 分页列表不返回大字段 |
| 深分页 | 大数据量时考虑游标分页或基于 ID 翻页 |
| 多表分页 | 复杂 JOIN 要关注 COUNT SQL |
| 查询条件 | 高频条件必须评估索引 |
普通管理系统可以使用 Page 分页;大数据量流水表、日志表和消息表需要考虑深分页优化。
批量新增实现
批量新增适合导入、初始化、批量配置等场景。Service 层应控制批次大小,并在批量操作前进行数据清洗和重复校验。
@Transactional(rollbackFor = Exception.class)
public List<Long> batchAddUser(List<UserAddDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return Collections.emptyList();
}
List<UserEntity> entities = dtoList.stream()
.peek(dto -> {
this.checkUsernameUnique(dto.getUsername(), null);
this.checkPhoneUnique(dto.getPhone(), null);
})
.map(userConvert::toEntity)
.toList();
this.saveBatch(entities, 1000);
log.info("批量新增用户成功,数量:{}", entities.size());
return entities.stream().map(UserEntity::getId).toList();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
批量新增注意事项:
| 检查项 | 建议 |
|---|---|
| 批次大小 | 建议每批 500 到 2000,按压测调整 |
| 重复校验 | 先校验请求内重复,再校验数据库重复 |
| 事务范围 | 大批量导入不建议一个事务包全部 |
| 错误处理 | 导入场景建议返回失败明细 |
| 内存控制 | 不要一次加载超大文件全部数据 |
| 唯一索引 | 数据库唯一约束必须保留 |
如果是 Excel 导入,通常不建议遇到第一条错误就回滚全部,而是收集错误明细后统一返回。
批量修改实现
批量修改适合批量启停、批量调整排序、批量更新状态等场景。批量修改前应确认每条数据是否存在,并校验状态是否允许变更。
@Transactional(rollbackFor = Exception.class)
public void batchUpdateUser(List<UserUpdateDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return;
}
List<UserEntity> entities = dtoList.stream()
.map(dto -> {
UserEntity entity = this.getRequiredById(dto.getId());
userConvert.updateEntity(dto, entity);
return entity;
})
.toList();
this.updateBatchById(entities, 1000);
log.info("批量修改用户成功,数量:{}", entities.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
如果批量修改的是同一个字段,例如批量禁用用户,可以使用 lambdaUpdate:
@Transactional(rollbackFor = Exception.class)
public void batchDisableUser(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return;
}
boolean updated = this.lambdaUpdate()
.in(UserEntity::getId, ids)
.set(UserEntity::getStatus, UserStatusEnum.DISABLED)
.update();
if (!updated) {
throw new IllegalStateException("批量禁用用户失败");
}
log.info("批量禁用用户成功,数量:{}", ids.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
批量修改不同字段值时,使用 updateBatchById 更直观;批量修改相同字段值时,使用条件更新效率通常更高。
批量删除实现
批量删除应先校验参数,再执行删除。对于核心数据,应逐条检查是否允许删除;对于简单数据,可以直接使用 removeBatchByIds。
@Transactional(rollbackFor = Exception.class)
public void batchDeleteUser(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return;
}
boolean removed = this.removeBatchByIds(ids, 1000);
if (!removed) {
throw new IllegalStateException("批量删除用户失败");
}
log.info("批量删除用户成功,数量:{}", ids.size());
}2
3
4
5
6
7
8
9
10
11
12
13
如果删除前需要校验是否存在系统内置用户,可以先查出数据再判断:
@Transactional(rollbackFor = Exception.class)
public void batchDeleteUserWithCheck(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return;
}
List<UserEntity> users = this.listByIds(ids);
if (users.size() != ids.size()) {
throw new IllegalArgumentException("部分用户不存在");
}
boolean hasAdmin = users.stream()
.anyMatch(user -> Objects.equals(user.getUsername(), "admin"));
if (hasAdmin) {
throw new IllegalArgumentException("系统管理员不允许删除");
}
this.removeBatchByIds(ids, 1000);
log.info("批量删除用户完成,数量:{}", ids.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
批量删除注意事项:
| 检查项 | 建议 |
|---|---|
| 空集合 | 直接返回 |
| 数据存在性 | 核心数据建议检查 |
| 内置数据 | 禁止删除管理员、默认角色、内置字典 |
| 引用关系 | 被引用数据禁止删除或提示先解绑 |
| 删除方式 | 优先逻辑删除 |
| 批次大小 | 大批量按批次删除 |
幂等业务处理
幂等是指同一个业务请求执行一次和执行多次的结果一致。新增、支付回调、消息消费、批量导入、外部系统推送等场景必须考虑幂等。
常见幂等方案如下:
| 方案 | 适用场景 |
|---|---|
| 唯一索引 | 订单号、请求号、业务编码唯一 |
| 幂等表 | 外部请求、支付回调、消息消费 |
| 状态机 | 订单状态、审批状态、支付状态 |
| Redis 锁 | 短时间重复提交 |
| Token 机制 | 表单防重复提交 |
| MQ 消费记录 | 消费幂等 |
简单业务可以使用唯一索引实现幂等。例如用户名唯一、订单号唯一,重复提交时数据库会拒绝重复数据。外部请求建议增加请求流水号。
幂等记录表示例:
CREATE TABLE biz_idempotent_record (
id BIGINT NOT NULL COMMENT '主键ID',
request_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求流水号',
biz_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '业务类型',
biz_id BIGINT NOT NULL DEFAULT 0 COMMENT '业务ID',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0处理中,1成功,2失败',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_request_no_biz_type (request_no, biz_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幂等记录表';2
3
4
5
6
7
8
9
10
11
幂等处理建议放在 Service 层或专门的幂等组件中,不要散落在 Controller 中。对于支付回调、MQ 消费这类场景,必须先判断请求是否已处理,再执行业务变更。
业务校验处理
业务校验分为参数格式校验和业务规则校验。参数格式校验一般由 Controller 层结合 Jakarta Validation 完成;业务规则校验应放在 Service 层。
Service 层常见业务校验如下:
| 校验类型 | 示例 |
|---|---|
| 唯一性校验 | 用户名、手机号、角色编码、订单号 |
| 存在性校验 | 用户是否存在、角色是否存在 |
| 状态校验 | 已禁用用户不能登录、已完成订单不能修改 |
| 权限校验 | 当前用户是否有权操作目标数据 |
| 引用校验 | 角色已绑定用户时不能删除 |
| 范围校验 | 金额不能小于 0,库存不能超卖 |
| 租户校验 | 当前租户只能操作自己的数据 |
业务校验示例:
private void checkUsernameUnique(String username, Long excludeId) {
if (StrUtil.isBlank(username)) {
return;
}
long count = this.lambdaQuery()
.eq(UserEntity::getUsername, username)
.ne(ObjectUtil.isNotNull(excludeId), UserEntity::getId, excludeId)
.count();
if (count > 0) {
throw new IllegalArgumentException("用户名已存在");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
业务校验建议:
| 规范项 | 建议 |
|---|---|
| 校验位置 | 放在 Service 层 |
| 错误提示 | 面向用户,明确可理解 |
| 数据库约束 | 与唯一索引、外键或业务约束配合 |
| 并发问题 | 校验后仍需依赖数据库约束兜底 |
| 复用 | 模块内私有方法或独立校验组件 |
| 异常类型 | 项目中应统一业务异常类型 |
Service 层不要只依赖前端校验。前端校验用于用户体验,后端校验用于安全和一致性。
事务边界设计
事务边界应放在 Service 层。Controller 不应直接开启事务,Mapper 也不应感知事务。涉及多次数据库写操作、多表写操作、写数据库后再写关联表、批量操作、状态流转等场景,应使用 @Transactional(rollbackFor = Exception.class)。
事务使用示例:
@Transactional(rollbackFor = Exception.class)
public Long addUser(UserAddDTO dto) {
this.checkUsernameUnique(dto.getUsername(), null);
UserEntity entity = userConvert.toEntity(dto);
this.save(entity);
// 示例:保存用户角色关系、用户岗位关系等关联数据
// userRoleService.saveUserRoles(entity.getId(), dto.getRoleIds());
log.info("新增用户成功,用户ID:{}", entity.getId());
return entity.getId();
}2
3
4
5
6
7
8
9
10
11
12
13
事务设计建议:
| 场景 | 是否需要事务 |
|---|---|
| 单条查询 | 不需要 |
| 单表单条新增 | 可不显式加,但加上也可以 |
| 多表新增 | 必须加 |
| 多表修改 | 必须加 |
| 批量导入 | 需要按批次设计事务 |
| 删除并解除关联 | 必须加 |
| 状态流转 | 建议加 |
| 查询后调用外部接口 | 谨慎,避免长事务 |
事务常见失效原因:
| 原因 | 说明 |
|---|---|
| 同类内部方法调用 | this.method() 不经过代理,事务可能不生效 |
| 方法不是 public | Spring 默认代理 public 方法更常见 |
| 异常被捕获未抛出 | 事务无法感知异常 |
| 默认只回滚运行时异常 | 建议配置 rollbackFor = Exception.class |
| 异步方法中事务 | 新线程不继承当前事务 |
| 数据源不一致 | 多数据源场景要单独处理 |
| 事务范围过大 | 外部 HTTP、MQ、文件操作不宜放在长事务中 |
事务中不建议执行耗时外部调用,例如 HTTP 请求、文件上传、发送 MQ、发送短信等。对于“事务提交后发送消息”的场景,可以使用事务事件、消息表或本地事务消息方案。
Service 单元测试
Service 单元测试用于验证业务规则、事务、对象转换、异常分支和数据写入结果。与 Mapper 测试不同,Service 测试关注业务语义,而不是单条 SQL 是否正确。
测试配置建议使用独立测试库,并启用 test Profile。
文件位置:src/test/java/io/github/atengk/module/system/user/service/UserServiceTest.java
package io.github.atengk.module.system.user.service;
import cn.hutool.core.collection.CollUtil;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.vo.UserDetailVO;
import io.github.atengk.module.system.user.vo.UserPageVO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.math.BigDecimal;
import java.util.List;
/**
* 用户服务测试
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@SpringBootTest
@ActiveProfiles("test")
@MapperScan("io.github.atengk.**.mapper")
class UserServiceTest {
@Autowired
private UserService userService;
/**
* 测试新增用户
*/
@Test
void testAddUser() {
UserAddDTO dto = new UserAddDTO();
dto.setUsername("test_user_001");
dto.setNickname("测试用户001");
dto.setPhone("13800000001");
dto.setEmail("test001@example.com");
dto.setBalanceAmount(BigDecimal.ZERO);
dto.setStatus(1);
dto.setSortOrder(0);
dto.setRemark("Service测试新增用户");
Long userId = userService.addUser(dto);
Assertions.assertNotNull(userId);
log.info("测试新增用户完成,用户ID:{}", userId);
}
/**
* 测试修改用户
*/
@Test
void testUpdateUser() {
UserAddDTO addDTO = new UserAddDTO();
addDTO.setUsername("test_user_002");
addDTO.setNickname("测试用户002");
addDTO.setPhone("13800000002");
addDTO.setEmail("test002@example.com");
addDTO.setBalanceAmount(BigDecimal.ZERO);
addDTO.setStatus(1);
Long userId = userService.addUser(addDTO);
UserUpdateDTO updateDTO = new UserUpdateDTO();
updateDTO.setId(userId);
updateDTO.setNickname("测试用户002-已修改");
updateDTO.setPhone("13800000003");
updateDTO.setEmail("test002_update@example.com");
updateDTO.setBalanceAmount(new BigDecimal("100.00"));
updateDTO.setStatus(1);
userService.updateUser(updateDTO);
UserDetailVO detailVO = userService.getUserDetail(userId);
Assertions.assertEquals("测试用户002-已修改", detailVO.getNickname());
log.info("测试修改用户完成,用户ID:{}", userId);
}
/**
* 测试分页查询用户
*/
@Test
void testPageUser() {
UserPageQuery query = new UserPageQuery();
query.setPageNum(1L);
query.setPageSize(10L);
query.setUsername("test");
PageResult<UserPageVO> pageResult = userService.pageUser(query);
Assertions.assertNotNull(pageResult);
Assertions.assertNotNull(pageResult.getRecords());
log.info("测试分页查询用户完成,总数:{},当前页数量:{}",
pageResult.getTotal(), pageResult.getRecords().size());
}
/**
* 测试批量新增用户
*/
@Test
void testBatchAddUser() {
UserAddDTO user1 = new UserAddDTO();
user1.setUsername("batch_user_001");
user1.setNickname("批量用户001");
user1.setPhone("13900000001");
user1.setEmail("batch001@example.com");
user1.setBalanceAmount(BigDecimal.ZERO);
user1.setStatus(1);
UserAddDTO user2 = new UserAddDTO();
user2.setUsername("batch_user_002");
user2.setNickname("批量用户002");
user2.setPhone("13900000002");
user2.setEmail("batch002@example.com");
user2.setBalanceAmount(BigDecimal.ZERO);
user2.setStatus(1);
List<Long> ids = userService.batchAddUser(List.of(user1, user2));
Assertions.assertTrue(CollUtil.isNotEmpty(ids));
Assertions.assertEquals(2, ids.size());
log.info("测试批量新增用户完成,用户ID列表:{}", ids);
}
/**
* 测试用户名重复异常
*/
@Test
void testAddUserDuplicateUsername() {
UserAddDTO dto = new UserAddDTO();
dto.setUsername("duplicate_user_001");
dto.setNickname("重复用户001");
dto.setPhone("13700000001");
dto.setEmail("duplicate001@example.com");
dto.setBalanceAmount(BigDecimal.ZERO);
dto.setStatus(1);
userService.addUser(dto);
UserAddDTO duplicateDTO = new UserAddDTO();
duplicateDTO.setUsername("duplicate_user_001");
duplicateDTO.setNickname("重复用户002");
duplicateDTO.setPhone("13700000002");
duplicateDTO.setEmail("duplicate002@example.com");
duplicateDTO.setBalanceAmount(BigDecimal.ZERO);
duplicateDTO.setStatus(1);
Assertions.assertThrows(IllegalArgumentException.class, () -> userService.addUser(duplicateDTO));
log.info("测试用户名重复异常完成");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
Service 测试重点如下:
| 测试项 | 验证内容 |
|---|---|
| 新增成功 | 是否生成 ID,字段是否正确入库 |
| 修改成功 | 字段是否正确更新 |
| 删除成功 | 逻辑删除是否生效 |
| 详情查询 | VO 字段、脱敏、枚举回显是否正确 |
| 分页查询 | 分页参数、总数、列表是否正确 |
| 批量操作 | 批量数据是否全部处理 |
| 唯一校验 | 重复用户名、手机号是否抛出异常 |
| 事务回滚 | 中途异常时数据是否回滚 |
| 空数据 | 空列表、空条件、无记录是否正常 |
| 异常分支 | 不存在 ID、非法状态、无权限等场景 |
Service 测试不应只覆盖成功路径。业务系统中真正容易出问题的是重复提交、并发修改、异常回滚、状态非法流转、批量部分失败和数据权限边界,这些都应逐步补充到测试用例中。
Controller 层开发
Controller 层负责接收 HTTP 请求、完成基础参数校验、调用 Service、封装统一响应和生成接口文档。Controller 不应直接访问 Mapper,也不应编写复杂业务逻辑。业务校验、事务控制、对象转换、权限判断和数据库操作应下沉到 Service 层。
RESTful 接口设计
RESTful 接口设计应围绕资源展开。以用户资源为例,推荐使用 /api/v1/users 作为统一资源路径,通过 HTTP 方法表达操作语义。
推荐接口设计如下:
| 操作 | 请求方法 | 路径 | 说明 |
|---|---|---|---|
| 新增用户 | POST | /api/v1/users | 创建用户 |
| 修改用户 | PUT | /api/v1/users/{id} | 修改指定用户 |
| 删除用户 | DELETE | /api/v1/users/{id} | 删除指定用户 |
| 批量删除用户 | DELETE | /api/v1/users/batch | 批量删除用户 |
| 查询详情 | GET | /api/v1/users/{id} | 查询指定用户详情 |
| 查询列表 | GET | /api/v1/users/list | 查询用户列表,不分页 |
| 分页查询 | GET | /api/v1/users/page | 分页查询用户 |
| 导入用户 | POST | /api/v1/users/import | 上传 Excel 导入 |
| 导出用户 | GET | /api/v1/users/export | 导出 Excel |
接口命名建议如下:
| 规范项 | 建议 |
|---|---|
| 资源路径 | 使用名词复数,例如 /users、/roles、/orders |
| 版本号 | 推荐放在路径中,例如 /api/v1 |
| 新增 | 使用 POST |
| 修改 | 使用 PUT 或 PATCH |
| 删除 | 使用 DELETE |
| 查询 | 使用 GET |
| 批量操作 | 可使用 /batch 后缀 |
| 导入导出 | 可使用 /import、/export |
| 路径参数 | 使用 {id} 表示资源 ID |
| 查询参数 | 用于筛选、分页、排序 |
Controller 层建议统一返回 ApiResult<T>,分页接口返回 ApiResult<PageResult<T>>。统一响应对象的完整设计会在后续「通用返回与异常处理」章节展开,本章节先给出可运行的基础结构。
新增接口
新增接口使用 POST,请求体使用 @RequestBody 接收新增 DTO,并使用 @Valid 触发参数校验。新增接口通常返回新增数据 ID。
请求示例:
POST /api/v1/users
Content-Type: application/json
{
"username": "admin",
"nickname": "管理员",
"phone": "13800000000",
"email": "admin@example.com",
"balanceAmount": 0.00,
"status": 1,
"sortOrder": 0,
"remark": "系统管理员"
}2
3
4
5
6
7
8
9
10
11
12
13
返回示例:
{
"code": 200,
"message": "操作成功",
"data": 1900000000000000001,
"success": true
}2
3
4
5
6
新增接口注意事项:
| 检查项 | 建议 |
|---|---|
| 参数校验 | DTO 使用 Jakarta Validation |
| 重复校验 | 放在 Service 层,例如用户名、手机号 |
| 租户字段 | 不信任前端传入,后端从上下文获取 |
| 创建人 | 通过自动填充处理 |
| 返回值 | 返回主键 ID 或详情 VO |
| 日志 | 记录关键操作,不记录密码等敏感字段 |
修改接口
修改接口使用 PUT。路径中携带资源 ID,请求体携带修改内容。为了避免路径 ID 和请求体 ID 不一致,建议在 Controller 层将路径 ID 设置到 DTO 中。
请求示例:
PUT /api/v1/users/1900000000000000001
Content-Type: application/json
{
"nickname": "管理员-已修改",
"phone": "13800000001",
"email": "admin_update@example.com",
"balanceAmount": 100.00,
"status": 1,
"sortOrder": 1,
"version": 0,
"remark": "修改用户信息"
}2
3
4
5
6
7
8
9
10
11
12
13
返回示例:
{
"code": 200,
"message": "操作成功",
"data": null,
"success": true
}2
3
4
5
6
修改接口注意事项:
| 检查项 | 建议 |
|---|---|
| 路径 ID | 以路径 ID 为准 |
| 请求体 ID | 可以不传,或由 Controller 覆盖 |
| 空字段策略 | 明确空值是忽略还是清空 |
| 乐观锁 | 并发敏感数据建议携带 version |
| 业务校验 | 放在 Service 层 |
| 日志 | 记录用户 ID 和操作结果 |
删除接口
删除接口使用 DELETE /api/v1/users/{id}。默认建议做逻辑删除,不建议物理删除核心业务表。
请求示例:
DELETE /api/v1/users/1900000000000000001返回示例:
{
"code": 200,
"message": "操作成功",
"data": null,
"success": true
}2
3
4
5
6
删除接口注意事项:
| 检查项 | 建议 |
|---|---|
| 数据存在性 | Service 层校验 |
| 引用关系 | 被引用数据不允许删除 |
| 内置数据 | 管理员、默认角色等禁止删除 |
| 删除方式 | 普通业务表优先逻辑删除 |
| 操作日志 | 删除属于敏感操作,应记录 |
批量删除接口
批量删除接口用于一次删除多条数据。请求体建议传 ID 数组,不建议使用逗号字符串。
请求示例:
DELETE /api/v1/users/batch
Content-Type: application/json
[
1900000000000000001,
1900000000000000002
]2
3
4
5
6
7
返回示例:
{
"code": 200,
"message": "操作成功",
"data": null,
"success": true
}2
3
4
5
6
批量删除接口注意事项:
| 检查项 | 建议 |
|---|---|
| 空数组 | 使用 @NotEmpty 校验 |
| 数量限制 | 建议限制单次最大删除数量 |
| 引用校验 | 核心业务数据逐条校验 |
| 删除策略 | 优先逻辑删除 |
| 事务 | 批量删除应放在 Service 事务中 |
详情接口
详情接口使用 GET /api/v1/users/{id},返回详情 VO。详情接口可以返回比分页列表更多的字段,例如角色名称、部门名称、字典回显、枚举名称等。
请求示例:
GET /api/v1/users/1900000000000000001返回示例:
{
"code": 200,
"message": "操作成功",
"data": {
"id": 1900000000000000001,
"tenantId": 0,
"username": "admin",
"nickname": "管理员",
"phone": "138****0000",
"email": "admin@example.com",
"balanceAmount": 0.00,
"status": 1,
"statusName": "启用",
"roleNames": ["管理员"],
"sortOrder": 0,
"createTime": "2026-05-05 10:00:00",
"updateTime": "2026-05-05 10:00:00",
"remark": "系统管理员"
},
"success": true
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
详情接口注意事项:
| 检查项 | 建议 |
|---|---|
| 敏感字段 | 密码、盐值、密钥不返回 |
| 脱敏字段 | 手机号、身份证号、邮箱按权限脱敏 |
| 关联字段 | 可在 Service 层组装 |
| 不存在数据 | 统一抛出业务异常 |
| 数据权限 | 查询前或查询后校验访问权限 |
列表接口
列表接口用于返回不分页的小数据集合,例如下拉选项、启用用户列表、角色列表、字典列表等。对于可能出现大量数据的资源,应优先提供分页接口。
请求示例:
GET /api/v1/users/list?username=admin&status=1返回示例:
{
"code": 200,
"message": "操作成功",
"data": [
{
"id": 1900000000000000001,
"username": "admin",
"nickname": "管理员",
"phone": "138****0000",
"status": 1,
"statusName": "启用",
"createTime": "2026-05-05 10:00:00"
}
],
"success": true
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
列表接口注意事项:
| 检查项 | 建议 |
|---|---|
| 数据量 | 不确定数据量时不要提供无分页列表 |
| 查询条件 | 支持必要条件,不做全量大表查询 |
| 返回字段 | 保持轻量 |
| 排序规则 | 明确默认排序 |
| 缓存 | 字典、菜单等低频变化数据可缓存 |
分页接口
分页接口用于后台管理列表页,是最常见的查询接口。分页参数可以通过 Query 对象接收,返回统一分页结果。
请求示例:
GET /api/v1/users/page?pageNum=1&pageSize=10&username=admin&status=1返回示例:
{
"code": 200,
"message": "操作成功",
"data": {
"pageNum": 1,
"pageSize": 10,
"total": 1,
"records": [
{
"id": 1900000000000000001,
"username": "admin",
"nickname": "管理员",
"phone": "138****0000",
"status": 1,
"statusName": "启用",
"createTime": "2026-05-05 10:00:00"
}
]
},
"success": true
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
分页接口注意事项:
| 检查项 | 建议 |
|---|---|
| 页码 | pageNum >= 1 |
| 条数 | 限制最大 pageSize |
| 排序字段 | 必须白名单校验 |
| 默认排序 | 必须稳定,例如 create_time DESC |
| 深分页 | 大数据量表需要优化 |
| 大字段 | 列表接口不返回大字段 |
导入接口
导入接口通常使用 multipart/form-data 上传 Excel 文件。Controller 只做文件是否为空、文件后缀是否合法等基础校验,具体解析、校验、入库、失败明细处理应放在 Service 层。
请求示例:
POST /api/v1/users/import
Content-Type: multipart/form-data
file=@用户导入模板.xlsx2
3
4
返回示例:
{
"code": 200,
"message": "操作成功",
"data": {
"totalCount": 100,
"successCount": 98,
"failureCount": 2,
"failureMessages": [
"第3行:手机号不能为空",
"第8行:用户名已存在"
]
},
"success": true
}2
3
4
5
6
7
8
9
10
11
12
13
14
导入接口注意事项:
| 检查项 | 建议 |
|---|---|
| 文件大小 | 限制上传大小 |
| 文件类型 | 只允许 xls、xlsx |
| 参数校验 | 行级数据校验放 Service |
| 重复数据 | 校验文件内重复和数据库重复 |
| 事务 | 根据业务决定全部回滚或部分成功 |
| 失败明细 | 建议返回行号和失败原因 |
| 大文件 | 使用流式读取,避免一次性加载 |
导出接口
导出接口一般使用 GET,查询参数与分页或列表查询类似。导出不应默认导出全量大表数据,建议增加条件限制、最大导出数量限制或异步导出任务。
请求示例:
GET /api/v1/users/export?username=admin&status=1导出接口注意事项:
| 检查项 | 建议 |
|---|---|
| 导出范围 | 不允许无限制全量导出大表 |
| 最大数量 | 设置最大导出条数 |
| 敏感字段 | 导出前做权限校验和脱敏 |
| 大数据量 | 使用异步导出 |
| 文件名 | 使用 UTF-8 编码 |
| 响应头 | 设置正确的 Content-Type 和 Content-Disposition |
接口参数校验
Spring Boot 3 使用 Jakarta Validation。Controller 类上添加 @Validated,请求体对象使用 @Valid,路径参数或请求参数使用 @NotNull、@NotBlank、@Min、@Max 等注解。
常见校验方式如下:
| 参数位置 | 写法 |
|---|---|
| 请求体对象 | @RequestBody @Valid UserAddDTO dto |
| 路径参数 | @PathVariable @NotNull Long id |
| 查询对象 | @Valid UserPageQuery query |
| 批量 ID | @RequestBody @NotEmpty List<Long> ids |
| 上传文件 | Controller 中手动校验 file.isEmpty() |
DTO 中只做格式校验,业务校验放 Service。比如用户名是否重复、角色是否存在、订单是否允许修改,不能只依赖 Validation 注解。
接口返回结构
接口返回结构建议统一,便于前端处理成功、失败、异常、分页和提示信息。完整设计会在后续章节展开,本章节先给出 Controller 可用的基础版本。
文件位置:src/main/java/io/github/atengk/common/result/ApiResult.java
package io.github.atengk.common.result;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 统一接口响应对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class ApiResult<T> implements Serializable {
/**
* 状态码
*/
private Integer code;
/**
* 提示信息
*/
private String message;
/**
* 响应数据
*/
private T data;
/**
* 是否成功
*/
private Boolean success;
/**
* 成功响应,不返回数据
*
* @return 统一响应对象
*/
public static ApiResult<Void> success() {
return success(null);
}
/**
* 成功响应,返回数据
*
* @param data 响应数据
* @param <T> 数据类型
* @return 统一响应对象
*/
public static <T> ApiResult<T> success(T data) {
ApiResult<T> result = new ApiResult<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
result.setSuccess(true);
return result;
}
/**
* 失败响应
*
* @param code 状态码
* @param message 提示信息
* @param <T> 数据类型
* @return 统一响应对象
*/
public static <T> ApiResult<T> fail(Integer code, String message) {
ApiResult<T> result = new ApiResult<>();
result.setCode(code);
result.setMessage(message);
result.setData(null);
result.setSuccess(false);
return result;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
统一响应结构建议:
| 字段 | 说明 |
|---|---|
code | 业务状态码 |
message | 用户可读提示 |
data | 响应数据 |
success | 是否成功 |
HTTP 状态码和业务状态码应配合使用。普通业务异常可以返回 HTTP 200 加业务失败码,也可以返回 4xx;团队需要统一规范,避免前端同时适配多套错误结构。
接口日志记录
Controller 层可以记录接口入口和关键操作参数,但不应记录敏感信息。更完整的接口日志通常通过 AOP、过滤器或网关统一处理。Controller 中只建议记录高价值操作,例如新增、修改、删除、导入、导出。
日志建议如下:
| 场景 | 建议 |
|---|---|
| 新增 | 记录操作对象关键标识 |
| 修改 | 记录 ID,不记录完整请求体 |
| 删除 | 记录 ID 或 ID 数量 |
| 导入 | 记录文件名、文件大小 |
| 导出 | 记录导出条件和操作人 |
| 查询 | 普通查询不建议大量记录 |
| 敏感字段 | 密码、Token、密钥不记录 |
日志示例:
log.info("新增用户请求,用户名:{}", dto.getUsername());
log.info("删除用户请求,用户ID:{}", id);
log.info("批量删除用户请求,数量:{}", ids.size());
log.info("导入用户请求,文件名:{},大小:{}", file.getOriginalFilename(), file.getSize());2
3
4
生产环境不建议在 Controller 中打印完整 JSON 请求体。完整接口日志应结合脱敏组件统一处理。
接口文档生成
Spring Boot 3 项目建议使用 SpringDoc 或 Knife4j OpenAPI 3 生成接口文档。Controller 类上使用 @Tag,接口方法上使用 @Operation,参数上可以使用 @Parameter 或 DTO 字段注解配合生成文档。
常用注解如下:
| 注解 | 用途 |
|---|---|
@Tag | 标记 Controller 分组 |
@Operation | 标记接口说明 |
@Parameter | 标记单个参数说明 |
@Schema | 标记 DTO、VO 字段说明 |
@Hidden | 隐藏接口或字段 |
DTO 字段可以配合 @Schema:
@Schema(description = "用户名", example = "admin")
@NotBlank(message = "用户名不能为空")
private String username;2
3
Controller 示例中会直接使用 @Tag 和 @Operation。字段级文档建议放在 DTO、VO 类中维护。
Controller 完整示例
下面给出用户模块 Controller 的完整示例。该类只负责 HTTP 接入、基础参数校验、日志记录和调用 Service,不直接处理数据库访问。
文件位置:src/main/java/io/github/atengk/module/system/user/controller/UserController.java
package io.github.atengk.module.system.user.controller;
import cn.hutool.core.io.file.FileNameUtil;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.service.UserService;
import io.github.atengk.module.system.user.vo.UserDetailVO;
import io.github.atengk.module.system.user.vo.UserImportResultVO;
import io.github.atengk.module.system.user.vo.UserPageVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 用户接口
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
@Tag(name = "用户管理", description = "用户新增、修改、删除、查询、导入、导出接口")
public class UserController {
private final UserService userService;
/**
* 新增用户
*
* @param dto 用户新增参数
* @return 用户 ID
*/
@PostMapping
@Operation(summary = "新增用户", description = "创建一个新的用户,并返回用户ID")
public ApiResult<Long> addUser(@RequestBody @Valid UserAddDTO dto) {
log.info("新增用户请求,用户名:{}", dto.getUsername());
Long userId = userService.addUser(dto);
return ApiResult.success(userId);
}
/**
* 修改用户
*
* @param id 用户 ID
* @param dto 用户修改参数
* @return 操作结果
*/
@PutMapping("/{id}")
@Operation(summary = "修改用户", description = "根据用户ID修改用户基础信息")
public ApiResult<Void> updateUser(
@Parameter(description = "用户ID", required = true)
@PathVariable @NotNull(message = "用户ID不能为空") Long id,
@RequestBody @Valid UserUpdateDTO dto) {
dto.setId(id);
log.info("修改用户请求,用户ID:{}", id);
userService.updateUser(dto);
return ApiResult.success();
}
/**
* 删除用户
*
* @param id 用户 ID
* @return 操作结果
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除用户", description = "根据用户ID删除用户,默认执行逻辑删除")
public ApiResult<Void> deleteUser(
@Parameter(description = "用户ID", required = true)
@PathVariable @NotNull(message = "用户ID不能为空") Long id) {
log.info("删除用户请求,用户ID:{}", id);
userService.deleteUser(id);
return ApiResult.success();
}
/**
* 批量删除用户
*
* @param ids 用户 ID 列表
* @return 操作结果
*/
@DeleteMapping("/batch")
@Operation(summary = "批量删除用户", description = "根据用户ID列表批量删除用户")
public ApiResult<Void> batchDeleteUser(
@RequestBody @NotEmpty(message = "用户ID列表不能为空") List<Long> ids) {
log.info("批量删除用户请求,数量:{}", ids.size());
userService.batchDeleteUser(ids);
return ApiResult.success();
}
/**
* 查询用户详情
*
* @param id 用户 ID
* @return 用户详情
*/
@GetMapping("/{id}")
@Operation(summary = "查询用户详情", description = "根据用户ID查询用户详情")
public ApiResult<UserDetailVO> getUserDetail(
@Parameter(description = "用户ID", required = true)
@PathVariable @NotNull(message = "用户ID不能为空") Long id) {
UserDetailVO detailVO = userService.getUserDetail(id);
return ApiResult.success(detailVO);
}
/**
* 查询用户列表
*
* @param query 查询参数
* @return 用户列表
*/
@GetMapping("/list")
@Operation(summary = "查询用户列表", description = "根据条件查询用户列表,不分页")
public ApiResult<List<UserPageVO>> listUser(@Valid UserPageQuery query) {
List<UserPageVO> records = userService.listUser(query);
return ApiResult.success(records);
}
/**
* 分页查询用户
*
* @param query 分页查询参数
* @return 用户分页结果
*/
@GetMapping("/page")
@Operation(summary = "分页查询用户", description = "根据条件分页查询用户")
public ApiResult<PageResult<UserPageVO>> pageUser(@Valid UserPageQuery query) {
PageResult<UserPageVO> pageResult = userService.pageUser(query);
return ApiResult.success(pageResult);
}
/**
* 导入用户
*
* @param file Excel 文件
* @return 导入结果
*/
@PostMapping("/import")
@Operation(summary = "导入用户", description = "上传Excel文件导入用户数据")
public ApiResult<UserImportResultVO> importUser(@RequestPart("file") MultipartFile file) {
this.checkImportFile(file);
log.info("导入用户请求,文件名:{},文件大小:{}", file.getOriginalFilename(), file.getSize());
UserImportResultVO resultVO = userService.importUser(file);
return ApiResult.success(resultVO);
}
/**
* 导出用户
*
* @param query 查询参数
* @param response HTTP 响应
*/
@GetMapping("/export")
@Operation(summary = "导出用户", description = "根据查询条件导出用户Excel文件")
public void exportUser(@Valid UserPageQuery query, HttpServletResponse response) {
log.info("导出用户请求,用户名:{},状态:{}", query.getUsername(), query.getStatus());
userService.exportUser(query, response);
}
/**
* 校验导入文件
*
* @param file 上传文件
*/
private void checkImportFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("导入文件不能为空");
}
String filename = file.getOriginalFilename();
String extName = FileNameUtil.extName(filename);
boolean validExcel = StrUtil.equalsAnyIgnoreCase(extName, "xls", "xlsx");
if (!validExcel) {
throw new IllegalArgumentException("只支持导入xls或xlsx格式文件");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
上面的 Controller 依赖 UserService 中增加导入导出方法。Service 接口可补充如下方法。
文件位置:src/main/java/io/github/atengk/module/system/user/service/UserService.java
package io.github.atengk.module.system.user.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.vo.UserDetailVO;
import io.github.atengk.module.system.user.vo.UserImportResultVO;
import io.github.atengk.module.system.user.vo.UserPageVO;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 用户服务接口
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserService extends IService<UserEntity> {
/**
* 新增用户
*
* @param dto 用户新增参数
* @return 用户 ID
*/
Long addUser(UserAddDTO dto);
/**
* 修改用户
*
* @param dto 用户修改参数
*/
void updateUser(UserUpdateDTO dto);
/**
* 删除用户
*
* @param id 用户 ID
*/
void deleteUser(Long id);
/**
* 查询用户详情
*
* @param id 用户 ID
* @return 用户详情
*/
UserDetailVO getUserDetail(Long id);
/**
* 查询用户列表
*
* @param query 查询参数
* @return 用户列表
*/
List<UserPageVO> listUser(UserPageQuery query);
/**
* 分页查询用户
*
* @param query 分页查询参数
* @return 分页结果
*/
PageResult<UserPageVO> pageUser(UserPageQuery query);
/**
* 批量删除用户
*
* @param ids 用户 ID 列表
*/
void batchDeleteUser(List<Long> ids);
/**
* 导入用户
*
* @param file Excel 文件
* @return 导入结果
*/
UserImportResultVO importUser(MultipartFile file);
/**
* 导出用户
*
* @param query 查询参数
* @param response HTTP 响应
*/
void exportUser(UserPageQuery query, HttpServletResponse response);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
导入结果 VO 示例:
文件位置:src/main/java/io/github/atengk/module/system/user/vo/UserImportResultVO.java
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.util.Collections;
import java.util.List;
/**
* 用户导入结果返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserImportResultVO {
/**
* 总数量
*/
private Integer totalCount;
/**
* 成功数量
*/
private Integer successCount;
/**
* 失败数量
*/
private Integer failureCount;
/**
* 失败信息
*/
private List<String> failureMessages;
/**
* 创建空导入结果
*
* @return 导入结果
*/
public static UserImportResultVO empty() {
UserImportResultVO resultVO = new UserImportResultVO();
resultVO.setTotalCount(0);
resultVO.setSuccessCount(0);
resultVO.setFailureCount(0);
resultVO.setFailureMessages(Collections.emptyList());
return resultVO;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Controller 开发规范总结
Controller 层建议遵循以下规范:
| 规范项 | 建议 |
|---|---|
| 分层职责 | Controller 只处理 HTTP 接入,不写业务逻辑 |
| 参数校验 | 请求体使用 @Valid,路径参数使用 @NotNull |
| 返回结构 | 统一使用 ApiResult<T> |
| 分页返回 | 统一使用 PageResult<T> |
| 日志记录 | 记录关键操作,不记录敏感字段 |
| 接口文档 | 使用 OpenAPI 注解维护 |
| 文件上传 | Controller 做基础校验,Service 做解析和入库 |
| 文件导出 | 控制导出范围,避免全量大表导出 |
| 异常处理 | 不在 Controller 中大量 try-catch,交给全局异常处理 |
| 权限控制 | 通过注解、拦截器或安全框架统一处理 |
Controller 层越薄,Service 层越清晰,项目后续维护成本越低。普通业务系统中,Controller 方法通常只需要 3 到 8 行代码:接收参数、记录必要日志、调用 Service、返回统一响应。
通用返回与异常处理
通用返回与异常处理用于统一接口响应格式、业务状态码、成功响应、失败响应、参数校验异常、业务异常、数据库异常、唯一约束异常和权限异常。项目中不建议 Controller 到处 try-catch,也不建议每个接口返回不同结构。推荐由统一响应对象和全局异常处理器集中处理。
统一响应对象
统一响应对象用于规范所有接口返回结构。普通接口返回 ApiResult<T>,分页接口返回 ApiResult<PageResult<T>>,异常情况统一返回失败结构。
推荐响应结构如下:
| 字段 | 说明 |
|---|---|
code | 业务状态码 |
message | 响应提示 |
data | 响应数据 |
success | 是否成功 |
timestamp | 响应时间 |
文件位置:src/main/java/io/github/atengk/common/result/ApiResult.java
package io.github.atengk.common.result;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 统一接口响应对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class ApiResult<T> implements Serializable {
private Integer code;
private String message;
private T data;
private Boolean success;
private LocalDateTime timestamp;
public static ApiResult<Void> success() {
return success(null);
}
public static <T> ApiResult<T> success(T data) {
ApiResult<T> result = new ApiResult<>();
result.setCode(ResultCode.SUCCESS.getCode());
result.setMessage(ResultCode.SUCCESS.getMessage());
result.setData(data);
result.setSuccess(true);
result.setTimestamp(LocalDateTime.now());
return result;
}
public static <T> ApiResult<T> fail(ResultCode resultCode) {
return fail(resultCode.getCode(), resultCode.getMessage());
}
public static <T> ApiResult<T> fail(ResultCode resultCode, String message) {
return fail(resultCode.getCode(), message);
}
public static <T> ApiResult<T> fail(Integer code, String message) {
ApiResult<T> result = new ApiResult<>();
result.setCode(code);
result.setMessage(message);
result.setData(null);
result.setSuccess(false);
result.setTimestamp(LocalDateTime.now());
return result;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
统一响应对象不建议包含过多字段。traceId、path、method 等排查字段可以在项目引入链路追踪或接口日志后再扩展,避免一开始就把响应结构设计得过重。
状态码设计
状态码用于让前端、调用方和日志系统快速识别错误类型。建议区分 HTTP 状态码和业务状态码。HTTP 状态码表示请求协议层面的成功或失败,业务状态码表示系统内部业务结果。
推荐状态码设计如下:
| 状态码 | 名称 | 说明 |
|---|---|---|
200 | SUCCESS | 操作成功 |
400 | BAD_REQUEST | 请求参数错误 |
401 | UNAUTHORIZED | 未登录或认证失败 |
403 | FORBIDDEN | 无权限访问 |
404 | NOT_FOUND | 资源不存在 |
405 | METHOD_NOT_ALLOWED | 请求方法不支持 |
422 | VALIDATE_FAILED | 参数校验失败 |
500 | SYSTEM_ERROR | 系统异常 |
5001 | BUSINESS_ERROR | 业务异常 |
5002 | DATA_NOT_FOUND | 数据不存在 |
5003 | DATA_DUPLICATE | 数据重复 |
5004 | DATA_CONSTRAINT_ERROR | 数据约束异常 |
5005 | DATABASE_ERROR | 数据库异常 |
文件位置:src/main/java/io/github/atengk/common/result/ResultCode.java
package io.github.atengk.common.result;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 统一响应状态码
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum ResultCode {
SUCCESS(200, "操作成功"),
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "请先登录"),
FORBIDDEN(403, "无权限访问"),
NOT_FOUND(404, "资源不存在"),
METHOD_NOT_ALLOWED(405, "请求方法不支持"),
VALIDATE_FAILED(422, "参数校验失败"),
SYSTEM_ERROR(500, "系统异常,请稍后重试"),
BUSINESS_ERROR(5001, "业务处理失败"),
DATA_NOT_FOUND(5002, "数据不存在"),
DATA_DUPLICATE(5003, "数据已存在"),
DATA_CONSTRAINT_ERROR(5004, "数据约束异常"),
DATABASE_ERROR(5005, "数据库操作异常");
private final Integer code;
private final String message;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
状态码不宜设计得过细。常见业务异常可以使用 BUSINESS_ERROR 加自定义错误信息;只有前端需要特殊处理的场景,才需要独立状态码。
成功响应封装
成功响应由 ApiResult.success() 和 ApiResult.success(data) 统一处理。Controller 中不要手动拼接响应对象。
无数据返回示例:
return ApiResult.success();有数据返回示例:
Long userId = userService.addUser(dto);
return ApiResult.success(userId);2
分页返回示例:
PageResult<UserPageVO> pageResult = userService.pageUser(query);
return ApiResult.success(pageResult);2
成功响应建议:
| 场景 | 返回数据 |
|---|---|
| 新增接口 | 返回主键 ID 或详情对象 |
| 修改接口 | 返回空对象即可 |
| 删除接口 | 返回空对象即可 |
| 详情接口 | 返回详情 VO |
| 分页接口 | 返回 PageResult<T> |
| 导入接口 | 返回导入结果统计 |
| 导出接口 | 直接写文件流,不返回 ApiResult |
导出接口通常不返回 JSON,而是直接写入 HttpServletResponse。如果导出失败,应由全局异常处理器返回错误响应,或在导出前完成参数校验。
失败响应封装
失败响应由 ApiResult.fail(...) 统一处理。业务代码中不建议直接返回失败对象,推荐抛出业务异常,由全局异常处理器统一转换为响应。
直接失败响应适合少量特殊场景:
return ApiResult.fail(ResultCode.BAD_REQUEST, "请求参数不能为空");更推荐在 Service 层抛出业务异常:
throw new BusinessException(ResultCode.DATA_NOT_FOUND, "用户不存在");失败响应示例:
{
"code": 5002,
"message": "用户不存在",
"data": null,
"success": false,
"timestamp": "2026-05-05T10:00:00"
}2
3
4
5
6
7
失败响应建议:
| 场景 | 建议 |
|---|---|
| 参数格式错误 | 返回具体字段错误 |
| 业务规则失败 | 返回用户可理解的业务提示 |
| 权限不足 | 返回无权限提示,不暴露资源细节 |
| 数据库异常 | 返回通用提示,详细错误写日志 |
| 系统异常 | 返回通用提示,避免暴露堆栈 |
| 唯一约束冲突 | 返回明确字段提示,例如用户名已存在 |
不要把数据库原始异常、SQL、表名、字段名、连接信息、堆栈信息直接返回给前端。
全局异常处理
全局异常处理器用于集中捕获 Controller 层向外抛出的异常,并转换为统一响应对象。建议使用 @RestControllerAdvice 和 @ExceptionHandler。
文件位置:src/main/java/io/github/atengk/common/exception/GlobalExceptionHandler.java
package io.github.atengk.common.exception;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.exceptions.ExceptionUtil;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.common.result.ResultCode;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 全局异常处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ApiResult<Void> handleBusinessException(BusinessException exception) {
log.warn("业务异常,状态码:{},错误信息:{}", exception.getCode(), exception.getMessage());
return ApiResult.fail(exception.getCode(), exception.getMessage());
}
@ExceptionHandler(PermissionDeniedException.class)
public ApiResult<Void> handlePermissionDeniedException(PermissionDeniedException exception) {
log.warn("权限异常,错误信息:{}", exception.getMessage());
return ApiResult.fail(ResultCode.FORBIDDEN, exception.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResult<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException exception) {
String message = getBindingErrorMessage(exception.getBindingResult());
log.warn("请求体参数校验失败,错误信息:{}", message);
return ApiResult.fail(ResultCode.VALIDATE_FAILED, message);
}
@ExceptionHandler(BindException.class)
public ApiResult<Void> handleBindException(BindException exception) {
String message = getBindingErrorMessage(exception.getBindingResult());
log.warn("查询参数绑定失败,错误信息:{}", message);
return ApiResult.fail(ResultCode.VALIDATE_FAILED, message);
}
@ExceptionHandler(ConstraintViolationException.class)
public ApiResult<Void> handleConstraintViolationException(ConstraintViolationException exception) {
String message = getConstraintViolationMessage(exception.getConstraintViolations());
log.warn("路径参数或请求参数校验失败,错误信息:{}", message);
return ApiResult.fail(ResultCode.VALIDATE_FAILED, message);
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public ApiResult<Void> handleMissingServletRequestParameterException(MissingServletRequestParameterException exception) {
String message = StrUtil.format("缺少必要请求参数:{}", exception.getParameterName());
log.warn("缺少请求参数,参数名:{}", exception.getParameterName());
return ApiResult.fail(ResultCode.BAD_REQUEST, message);
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ApiResult<Void> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException exception) {
String message = StrUtil.format("请求参数类型不正确:{}", exception.getName());
log.warn("请求参数类型不正确,参数名:{},参数值:{}", exception.getName(), exception.getValue());
return ApiResult.fail(ResultCode.BAD_REQUEST, message);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ApiResult<Void> handleHttpMessageNotReadableException(HttpMessageNotReadableException exception) {
log.warn("请求体格式错误,错误信息:{}", getRootMessage(exception));
return ApiResult.fail(ResultCode.BAD_REQUEST, "请求体格式错误,请检查JSON格式或字段类型");
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ApiResult<Void> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException exception) {
String message = StrUtil.format("请求方法不支持:{}", exception.getMethod());
log.warn("请求方法不支持,请求方法:{},支持方法:{}", exception.getMethod(), exception.getSupportedHttpMethods());
return ApiResult.fail(ResultCode.METHOD_NOT_ALLOWED, message);
}
@ExceptionHandler(DuplicateKeyException.class)
public ApiResult<Void> handleDuplicateKeyException(DuplicateKeyException exception) {
String message = buildDuplicateKeyMessage(exception);
log.warn("唯一约束冲突,返回信息:{},原始错误:{}", message, getRootMessage(exception));
return ApiResult.fail(ResultCode.DATA_DUPLICATE, message);
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ApiResult<Void> handleDataIntegrityViolationException(DataIntegrityViolationException exception) {
log.error("数据库完整性约束异常,错误信息:{}", getRootMessage(exception), exception);
return ApiResult.fail(ResultCode.DATA_CONSTRAINT_ERROR, "数据不满足约束条件,请检查后重试");
}
@ExceptionHandler(DataAccessException.class)
public ApiResult<Void> handleDataAccessException(DataAccessException exception) {
log.error("数据库访问异常,错误信息:{}", getRootMessage(exception), exception);
return ApiResult.fail(ResultCode.DATABASE_ERROR, "数据库操作异常,请稍后重试");
}
@ExceptionHandler(IllegalArgumentException.class)
public ApiResult<Void> handleIllegalArgumentException(IllegalArgumentException exception) {
log.warn("非法参数异常,错误信息:{}", exception.getMessage());
return ApiResult.fail(ResultCode.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResult<Void> handleException(Exception exception) {
log.error("系统未知异常,错误信息:{}", getRootMessage(exception), exception);
return ApiResult.fail(ResultCode.SYSTEM_ERROR);
}
private String getBindingErrorMessage(BindingResult bindingResult) {
if (bindingResult == null || CollUtil.isEmpty(bindingResult.getFieldErrors())) {
return ResultCode.VALIDATE_FAILED.getMessage();
}
FieldError fieldError = bindingResult.getFieldErrors().get(0);
return StrUtil.blankToDefault(fieldError.getDefaultMessage(), ResultCode.VALIDATE_FAILED.getMessage());
}
private String getConstraintViolationMessage(Set<ConstraintViolation<?>> violations) {
if (CollUtil.isEmpty(violations)) {
return ResultCode.VALIDATE_FAILED.getMessage();
}
return violations.stream()
.map(ConstraintViolation::getMessage)
.filter(StrUtil::isNotBlank)
.collect(Collectors.joining(";"));
}
private String getRootMessage(Throwable throwable) {
String message = ExceptionUtil.getRootCauseMessage(throwable);
return StrUtil.blankToDefault(message, throwable.getMessage());
}
private String buildDuplicateKeyMessage(DuplicateKeyException exception) {
String rootMessage = StrUtil.nullToDefault(getRootMessage(exception), "");
String lowerMessage = rootMessage.toLowerCase(Locale.ROOT);
if (StrUtil.contains(lowerMessage, "username")) {
return "用户名已存在";
}
if (StrUtil.contains(lowerMessage, "phone")) {
return "手机号已存在";
}
if (StrUtil.contains(lowerMessage, "email")) {
return "邮箱已存在";
}
if (StrUtil.contains(lowerMessage, "role_code")) {
return "角色编码已存在";
}
if (StrUtil.contains(lowerMessage, "order_no")) {
return "订单号已存在";
}
return "数据已存在,请检查唯一字段";
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
全局异常处理器中,日志要记录原始异常,但返回给前端的信息要克制。数据库异常、系统异常、空指针异常等不应把原始错误暴露给前端。
参数校验异常处理
参数校验异常主要来自 Jakarta Validation。常见来源包括 @RequestBody @Valid、@Validated、@PathVariable @NotNull、@RequestParam @NotBlank、查询对象绑定失败等。
常见异常类型如下:
| 异常类型 | 触发场景 |
|---|---|
MethodArgumentNotValidException | @RequestBody @Valid 校验失败 |
BindException | 查询参数对象绑定或校验失败 |
ConstraintViolationException | 路径参数、请求参数校验失败 |
MissingServletRequestParameterException | 必填请求参数缺失 |
MethodArgumentTypeMismatchException | 参数类型转换失败 |
HttpMessageNotReadableException | JSON 格式错误或字段类型错误 |
DTO 示例:
package io.github.atengk.module.system.user.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 用户新增参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserAddDTO {
@NotBlank(message = "用户名不能为空")
@Size(max = 64, message = "用户名长度不能超过64个字符")
private String username;
@NotBlank(message = "昵称不能为空")
@Size(max = 64, message = "昵称长度不能超过64个字符")
private String nickname;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Controller 示例:
@PostMapping
public ApiResult<Long> addUser(@RequestBody @Valid UserAddDTO dto) {
Long userId = userService.addUser(dto);
return ApiResult.success(userId);
}2
3
4
5
校验失败返回示例:
{
"code": 422,
"message": "用户名不能为空",
"data": null,
"success": false,
"timestamp": "2026-05-05T10:00:00"
}2
3
4
5
6
7
参数校验只负责格式和基础边界。用户名是否重复、状态是否允许修改、角色是否存在、当前用户是否有权限操作目标数据,应放在 Service 层处理。
业务异常处理
业务异常用于表达可预期的业务失败,例如用户不存在、用户名重复、状态不允许修改、余额不足、订单已支付不能取消等。业务异常不属于系统错误,不应记录为 error 级别,通常使用 warn 即可。
文件位置:src/main/java/io/github/atengk/common/exception/BusinessException.java
package io.github.atengk.common.exception;
import io.github.atengk.common.result.ResultCode;
import lombok.Getter;
/**
* 业务异常
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
public class BusinessException extends RuntimeException {
private final Integer code;
public BusinessException(String message) {
super(message);
this.code = ResultCode.BUSINESS_ERROR.getCode();
}
public BusinessException(ResultCode resultCode) {
super(resultCode.getMessage());
this.code = resultCode.getCode();
}
public BusinessException(ResultCode resultCode, String message) {
super(message);
this.code = resultCode.getCode();
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Service 中使用示例:
private UserEntity getRequiredById(Long id) {
UserEntity entity = this.getById(id);
if (entity == null) {
throw new BusinessException(ResultCode.DATA_NOT_FOUND, "用户不存在");
}
return entity;
}2
3
4
5
6
7
唯一性校验示例:
private void checkUsernameUnique(String username, Long excludeId) {
long count = this.lambdaQuery()
.eq(UserEntity::getUsername, username)
.ne(excludeId != null, UserEntity::getId, excludeId)
.count();
if (count > 0) {
throw new BusinessException(ResultCode.DATA_DUPLICATE, "用户名已存在");
}
}2
3
4
5
6
7
8
9
10
业务异常建议:
| 场景 | 推荐异常 |
|---|---|
| 数据不存在 | BusinessException(ResultCode.DATA_NOT_FOUND, "用户不存在") |
| 数据重复 | BusinessException(ResultCode.DATA_DUPLICATE, "用户名已存在") |
| 状态不允许 | BusinessException("当前状态不允许修改") |
| 余额不足 | BusinessException("账户余额不足") |
| 权限不足 | 使用权限异常,不建议混用业务异常 |
| 参数格式错误 | 使用 Validation,不建议手动业务异常 |
前文示例中使用的 IllegalArgumentException 可以逐步替换为 BusinessException,这样异常语义更清晰,返回状态码也更统一。
数据库异常处理
数据库异常主要来自 Spring 的 DataAccessException 体系。常见场景包括 SQL 语法错误、字段不存在、连接失败、死锁、超时、约束冲突等。
常见异常类型如下:
| 异常类型 | 说明 |
|---|---|
DuplicateKeyException | 唯一约束冲突 |
DataIntegrityViolationException | 非空约束、外键约束、字段长度等完整性约束异常 |
DataAccessException | Spring 数据访问异常父类 |
CannotGetJdbcConnectionException | 获取数据库连接失败 |
QueryTimeoutException | 查询超时 |
DeadlockLoserDataAccessException | 死锁或并发冲突相关异常 |
数据库异常处理原则:
| 规范项 | 建议 |
|---|---|
| 日志级别 | 使用 error,记录完整异常堆栈 |
| 前端提示 | 返回通用提示,不暴露 SQL |
| 唯一约束 | 单独转成可读业务提示 |
| 连接异常 | 提示稍后重试 |
| SQL 异常 | 记录日志后返回系统或数据库异常 |
| 事务异常 | 确认是否正确回滚 |
数据库异常不应直接抛给前端。例如 MySQL 原始错误可能包含表名、字段名、索引名甚至 SQL 片段,直接返回存在安全和体验问题。
唯一约束异常处理
唯一约束异常是最常见的数据库异常之一。即使 Service 层提前做了唯一性校验,并发情况下仍可能出现两个请求同时通过校验,最终由数据库唯一索引兜底。因此唯一索引异常必须统一处理。
数据库唯一索引示例:
ALTER TABLE sys_user
ADD UNIQUE KEY uk_tenant_username (tenant_id, username);2
Service 层提前校验:
private void checkUsernameUnique(String username, Long excludeId) {
long count = this.lambdaQuery()
.eq(UserEntity::getUsername, username)
.ne(excludeId != null, UserEntity::getId, excludeId)
.count();
if (count > 0) {
throw new BusinessException(ResultCode.DATA_DUPLICATE, "用户名已存在");
}
}2
3
4
5
6
7
8
9
10
全局异常兜底:
@ExceptionHandler(DuplicateKeyException.class)
public ApiResult<Void> handleDuplicateKeyException(DuplicateKeyException exception) {
String message = buildDuplicateKeyMessage(exception);
log.warn("唯一约束冲突,返回信息:{},原始错误:{}", message, getRootMessage(exception));
return ApiResult.fail(ResultCode.DATA_DUPLICATE, message);
}2
3
4
5
6
唯一约束处理建议:
| 场景 | 建议 |
|---|---|
| 用户名重复 | 返回“用户名已存在” |
| 手机号重复 | 返回“手机号已存在” |
| 角色编码重复 | 返回“角色编码已存在” |
| 订单号重复 | 返回“订单号已存在” |
| 无法识别字段 | 返回“数据已存在,请检查唯一字段” |
不要只依赖 Service 层 count 查询做唯一性校验,数据库唯一索引才是并发下的数据一致性兜底。
权限异常处理
权限异常用于处理未登录、登录失效、无权限访问、越权操作等场景。权限控制可以由 Spring Security、Sa-Token、网关、拦截器或自定义权限组件实现。本章节先给出一个不依赖具体安全框架的自定义权限异常。
文件位置:src/main/java/io/github/atengk/common/exception/PermissionDeniedException.java
package io.github.atengk.common.exception;
/**
* 权限拒绝异常
*
* @author Ateng
* @since 2026-05-05
*/
public class PermissionDeniedException extends RuntimeException {
public PermissionDeniedException() {
super("无权限访问");
}
public PermissionDeniedException(String message) {
super(message);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Service 或权限组件中使用示例:
if (!hasPermission) {
throw new PermissionDeniedException("无权限操作当前用户数据");
}2
3
全局异常处理:
@ExceptionHandler(PermissionDeniedException.class)
public ApiResult<Void> handlePermissionDeniedException(PermissionDeniedException exception) {
log.warn("权限异常,错误信息:{}", exception.getMessage());
return ApiResult.fail(ResultCode.FORBIDDEN, exception.getMessage());
}2
3
4
5
权限异常建议:
| 场景 | 状态码 | 提示 |
|---|---|---|
| 未登录 | 401 | 请先登录 |
| 登录过期 | 401 | 登录已过期,请重新登录 |
| 无接口权限 | 403 | 无权限访问 |
| 无数据权限 | 403 | 无权限操作当前数据 |
| 越权访问 | 403 | 无权限访问 |
权限异常不要暴露过多资源信息。例如用户无权访问某条订单时,可以返回“无权限访问”或“数据不存在”,具体策略根据安全要求决定。
错误日志规范
错误日志用于排查线上问题。日志既要足够定位问题,又不能泄露敏感信息。全局异常处理器中,业务异常使用 warn,系统异常和数据库异常使用 error。
日志级别建议如下:
| 异常类型 | 日志级别 | 是否打印堆栈 |
|---|---|---|
| 参数校验异常 | warn | 否 |
| 业务异常 | warn | 否 |
| 权限异常 | warn | 否 |
| 唯一约束异常 | warn | 否,记录根因摘要即可 |
| 数据库异常 | error | 是 |
| 系统未知异常 | error | 是 |
| 第三方接口异常 | error 或 warn | 根据影响决定 |
推荐日志写法:
log.warn("业务异常,状态码:{},错误信息:{}", exception.getCode(), exception.getMessage());
log.error("数据库访问异常,错误信息:{}", getRootMessage(exception), exception);
log.error("系统未知异常,错误信息:{}", getRootMessage(exception), exception);2
3
4
5
不推荐日志写法:
log.error("异常:" + exception.getMessage());
log.info("请求参数:{}", password);
log.error("系统异常", exception.getMessage());2
3
4
5
错误日志注意事项:
| 规范项 | 建议 |
|---|---|
| 敏感信息 | 不记录密码、Token、密钥、身份证完整号 |
| SQL 信息 | 生产环境不长期打印完整 SQL 参数 |
| 业务异常 | 不要全部打成 error |
| 系统异常 | 必须打印堆栈 |
| 日志内容 | 包含业务 ID、用户 ID、请求号等定位信息 |
| 重复日志 | 不要 Controller、Service、全局异常都重复打印同一异常 |
| 外部接口 | 记录接口名、业务 ID、状态码,不直接打印敏感响应体 |
统一异常处理落地后,Controller 层应保持简洁,不需要在每个接口中写 try-catch。Service 层遇到业务失败时抛出 BusinessException,权限失败时抛出 PermissionDeniedException,数据库异常由 Spring 和全局异常处理器统一兜底。
条件构造器
条件构造器用于通过 Java API 动态拼接 SQL 条件。MyBatis-Plus 提供了 QueryWrapper、LambdaQueryWrapper、UpdateWrapper、LambdaUpdateWrapper 等常用构造器。普通业务查询应优先使用 Lambda 系列 Wrapper,避免硬编码数据库字段名;只有在动态字段、聚合查询、数据库函数、复杂 SQL 片段等场景下,才考虑使用非 Lambda Wrapper。
QueryWrapper 使用
QueryWrapper<T> 使用数据库字段名拼接条件,适合字段动态、聚合查询、函数查询、别名查询等场景。它的优点是灵活,缺点是字段名是字符串,重构实体属性时无法被编译器发现。
简单查询示例:
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.eq("username", "admin")
.eq("deleted", 0)
.orderByDesc("create_time");
List<UserEntity> users = userMapper.selectList(wrapper);2
3
4
5
6
指定查询字段示例:
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.select("id", "username", "nickname", "phone", "status", "create_time")
.eq("deleted", 0)
.orderByDesc("create_time");
List<UserEntity> users = userMapper.selectList(wrapper);2
3
4
5
6
QueryWrapper 适用场景:
| 场景 | 是否适合 |
|---|---|
| 普通单表查询 | 可用,但更推荐 LambdaQueryWrapper |
| 动态字段查询 | 适合 |
| 聚合查询 | 适合 |
| 数据库函数 | 适合 |
| 分组统计 | 适合 |
| 字段别名 | 适合 |
| 复杂业务条件 | 谨慎使用,优先考虑 XML |
使用 QueryWrapper 时必须控制字段来源。前端传入的字段名不能直接进入 select、orderBy、groupBy、apply、last 等方法,否则存在 SQL 注入风险。
LambdaQueryWrapper 使用
LambdaQueryWrapper<T> 使用实体类 Getter 方法引用字段,编译期更安全,重构字段名时更容易发现问题。普通单表查询、多条件筛选、列表查询和分页查询应优先使用 LambdaQueryWrapper。
基础示例:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getUsername, "admin")
.eq(UserEntity::getStatus, UserStatusEnum.ENABLED)
.orderByDesc(UserEntity::getCreateTime);
List<UserEntity> users = userMapper.selectList(wrapper);2
3
4
5
6
动态条件示例:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime);
List<UserEntity> users = userMapper.selectList(wrapper);2
3
4
5
6
7
8
LambdaQueryWrapper 使用建议:
| 规范项 | 建议 |
|---|---|
| 普通查询 | 优先使用 |
| 字段引用 | 使用 UserEntity::getXxx |
| 条件判断 | 使用条件参数控制是否拼接 |
| 字符串判空 | 使用 Hutool StrUtil.isNotBlank |
| 集合判空 | 使用 Hutool CollUtil.isNotEmpty |
| 对象判空 | 使用 Hutool ObjectUtil.isNotNull |
| 排序 | 使用实体属性方法引用 |
| 复杂 SQL | 不强行使用,必要时改用 XML |
UpdateWrapper 使用
UpdateWrapper<T> 使用数据库字段名拼接更新条件和更新字段,适合动态字段更新、批量条件更新、特殊 SQL 更新等场景。由于字段名是字符串,普通更新更推荐 LambdaUpdateWrapper。
示例:根据用户名修改状态。
UpdateWrapper<UserEntity> wrapper = new UpdateWrapper<>();
wrapper.eq("username", "admin")
.set("status", 1)
.set("update_time", LocalDateTime.now());
int rows = userMapper.update(null, wrapper);2
3
4
5
6
示例:局部字段更新。
UpdateWrapper<UserEntity> wrapper = new UpdateWrapper<>();
wrapper.eq("id", userId)
.set(StrUtil.isNotBlank(nickname), "nickname", nickname)
.set(StrUtil.isNotBlank(phone), "phone", phone)
.set("update_time", LocalDateTime.now());
int rows = userMapper.update(null, wrapper);2
3
4
5
6
7
UpdateWrapper 注意事项:
| 风险点 | 说明 |
|---|---|
| 缺少 WHERE | 可能误更新全表 |
| 字段硬编码 | 字段重构时编译器无法发现 |
| 前端字段透传 | 存在 SQL 注入风险 |
| 空值更新 | 要明确是否允许将字段更新为 null |
| 乐观锁 | 普通 Wrapper 更新可能绕过预期版本控制 |
| 自动填充 | 某些自定义更新方式可能不触发预期自动填充 |
业务系统中,除非确实需要动态字段更新,否则优先使用 LambdaUpdateWrapper。
LambdaUpdateWrapper 使用
LambdaUpdateWrapper<T> 使用实体类 Getter 方法引用字段,更适合普通条件更新、批量状态修改、逻辑业务字段修改等场景。
示例:根据 ID 修改用户状态。
LambdaUpdateWrapper<UserEntity> wrapper = new LambdaUpdateWrapper<UserEntity>()
.eq(UserEntity::getId, userId)
.set(UserEntity::getStatus, UserStatusEnum.DISABLED)
.set(UserEntity::getUpdateTime, LocalDateTime.now());
boolean updated = this.update(wrapper);2
3
4
5
6
示例:批量禁用用户。
public void batchDisableUser(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return;
}
boolean updated = this.lambdaUpdate()
.in(UserEntity::getId, ids)
.set(UserEntity::getStatus, UserStatusEnum.DISABLED)
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("批量禁用用户失败");
}
log.info("批量禁用用户成功,数量:{}", ids.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
示例:按条件修改手机号。
public void updatePhone(Long userId, String phone) {
if (ObjectUtil.isNull(userId)) {
throw new BusinessException("用户ID不能为空");
}
if (StrUtil.isBlank(phone)) {
throw new BusinessException("手机号不能为空");
}
boolean updated = this.lambdaUpdate()
.eq(UserEntity::getId, userId)
.set(UserEntity::getPhone, phone)
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("手机号修改失败,请刷新后重试");
}
log.info("修改用户手机号成功,用户ID:{}", userId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
LambdaUpdateWrapper 使用建议:
| 场景 | 建议 |
|---|---|
| 批量状态更新 | 推荐 |
| 单字段修改 | 推荐 |
| 条件更新 | 推荐 |
| 多字段动态更新 | 可用 |
| 复杂 SQL 更新 | 建议 XML |
| 前端动态字段更新 | 谨慎,必须字段白名单 |
更新 Wrapper 必须确保存在明确条件。禁止在没有 eq、in、between 等限制条件的情况下调用 update()。
条件拼接规范
条件拼接应使用 MyBatis-Plus 方法中的 condition 参数,而不是写大量 if 包裹。这样代码更紧凑,也更容易阅读。
推荐写法:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime());2
3
4
5
不推荐写法:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<>();
if (StrUtil.isNotBlank(query.getUsername())) {
wrapper.like(UserEntity::getUsername, query.getUsername());
}
if (query.getStatus() != null) {
wrapper.eq(UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()));
}
if (query.getStartTime() != null) {
wrapper.ge(UserEntity::getCreateTime, query.getStartTime());
}2
3
4
5
6
7
8
9
10
11
条件拼接规范:
| 类型 | 推荐工具 |
|---|---|
| 字符串 | StrUtil.isNotBlank(value) |
| 集合 | CollUtil.isNotEmpty(list) |
| 对象 | ObjectUtil.isNotNull(value) |
| 数字 | ObjectUtil.isNotNull(value) 或业务范围判断 |
| 时间 | ObjectUtil.isNotNull(startTime) |
| 布尔值 | 直接判断 Boolean.TRUE.equals(value) |
不要把空字符串、空集合、空时间直接拼接到 Wrapper 中。尤其是 in 条件,如果集合为空,必须避免生成异常 SQL 或无意义查询。
动态条件查询
动态条件查询是 Wrapper 最常用的场景。Service 层根据 Query 对象动态拼接查询条件,再返回列表或分页结果。
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserWrapperQueryService.java
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.system.user.convert.UserConvert;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.enums.UserStatusEnum;
import io.github.atengk.module.system.user.mapper.UserMapper;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.vo.UserPageVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
/**
* 用户条件构造器查询示例服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserWrapperQueryService extends ServiceImpl<UserMapper, UserEntity> {
private final UserConvert userConvert;
/**
* 根据动态条件查询用户列表
*
* @param query 用户分页查询参数
* @return 用户列表
*/
public List<UserPageVO> listByCondition(UserPageQuery query) {
LambdaQueryWrapper<UserEntity> wrapper = buildUserQueryWrapper(query);
List<UserEntity> users = this.list(wrapper);
if (CollUtil.isEmpty(users)) {
return Collections.emptyList();
}
log.info("动态条件查询用户列表完成,数量:{}", users.size());
return userConvert.toPageVOList(users);
}
/**
* 构建用户查询条件
*
* @param query 用户分页查询参数
* @return 查询条件构造器
*/
private LambdaQueryWrapper<UserEntity> buildUserQueryWrapper(UserPageQuery query) {
return new LambdaQueryWrapper<UserEntity>()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
动态查询建议将 Wrapper 构建逻辑抽取为私有方法,避免列表查询、分页查询、导出查询重复拼接相同条件。
模糊查询
模糊查询通常使用 like、likeLeft、likeRight。后台管理系统中最常见的是按名称、编码、手机号、邮箱等字段模糊搜索。
常用写法:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.likeRight(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone());2
3
不同模糊查询的 SQL 语义:
| 方法 | SQL 效果 | 场景 |
|---|---|---|
like | %value% | 名称、备注、关键字 |
likeLeft | %value | 后缀匹配,较少使用 |
likeRight | value% | 编码前缀、手机号前缀 |
notLike | NOT LIKE '%value%' | 排除关键字 |
模糊查询注意事项:
| 问题 | 建议 |
|---|---|
| 左模糊 | 可能导致普通索引失效 |
| 全表扫描 | 大表不要随意开放无索引模糊查询 |
| 关键字长度 | 限制最大长度 |
| 特殊字符 | 必要时处理 %、_ 等字符 |
| 多字段模糊 | 使用嵌套条件,避免条件优先级错误 |
| 高级搜索 | 大数据量场景考虑 Elasticsearch 或全文索引 |
多字段关键字搜索示例:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.and(StrUtil.isNotBlank(keyword), item -> item
.like(UserEntity::getUsername, keyword)
.or()
.like(UserEntity::getNickname, keyword)
.or()
.like(UserEntity::getPhone, keyword)
)
.orderByDesc(UserEntity::getCreateTime);2
3
4
5
6
7
8
9
范围查询
范围查询通常用于时间、金额、数量、排序值等字段。常用方法包括 between、notBetween、ge、gt、le、lt。
时间范围查询示例:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime);2
3
4
金额范围查询示例:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.ge(ObjectUtil.isNotNull(minAmount), UserEntity::getBalanceAmount, minAmount)
.le(ObjectUtil.isNotNull(maxAmount), UserEntity::getBalanceAmount, maxAmount);2
3
使用 between 示例:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.between(
ObjectUtil.isNotNull(startTime) && ObjectUtil.isNotNull(endTime),
UserEntity::getCreateTime,
startTime,
endTime
);2
3
4
5
6
7
范围查询建议:
| 场景 | 建议 |
|---|---|
| 起止时间都存在 | 可使用 between |
| 只有开始时间 | 使用 ge |
| 只有结束时间 | 使用 le |
| 金额范围 | 使用 BigDecimal |
| 日期范围 | 明确是否包含结束时间 |
| 查询当天 | 推荐 [当天 00:00:00, 次日 00:00:00) |
如果按日期查询当天数据,更推荐使用半开区间:
LocalDateTime start = localDate.atStartOfDay();
LocalDateTime end = localDate.plusDays(1).atStartOfDay();
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.ge(UserEntity::getCreateTime, start)
.lt(UserEntity::getCreateTime, end);2
3
4
5
6
IN 查询
IN 查询适合根据 ID 列表、状态列表、编码列表批量查询。使用前必须判断集合非空。
示例:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.in(CollUtil.isNotEmpty(ids), UserEntity::getId, ids)
.orderByDesc(UserEntity::getCreateTime);
List<UserEntity> users = this.list(wrapper);2
3
4
5
状态集合查询:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.in(CollUtil.isNotEmpty(statusList), UserEntity::getStatus, statusList);2
IN 查询注意事项:
| 问题 | 建议 |
|---|---|
| 空集合 | 必须提前返回或跳过条件 |
| 集合过大 | 控制数量,必要时分批查询 |
| 前端传入 | 限制最大数量 |
| 大批量 ID | 分批每次 500 到 2000 条 |
| 排序 | IN 不保证返回顺序,需要显式排序 |
| 性能 | 大量 IN 查询可能影响执行计划 |
分批查询示例:
public List<UserEntity> listByIdsInBatch(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return Collections.emptyList();
}
List<List<Long>> partitions = CollUtil.split(ids, 1000);
return partitions.stream()
.flatMap(partition -> this.lambdaQuery()
.in(UserEntity::getId, partition)
.list()
.stream())
.toList();
}2
3
4
5
6
7
8
9
10
11
12
13
EXISTS 查询
EXISTS 查询适合判断关联数据是否存在,例如查询已分配角色的用户、存在订单的客户、存在附件的业务单据。MyBatis-Plus Wrapper 提供 exists 和 notExists,但其中 SQL 片段需要自行编写,因此必须避免拼接前端参数。
示例:查询拥有角色的用户。
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.exists("""
SELECT 1
FROM sys_user_role ur
WHERE ur.user_id = sys_user.id
AND ur.deleted = 0
""");
List<UserEntity> users = userMapper.selectList(wrapper);2
3
4
5
6
7
8
9
示例:查询没有角色的用户。
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.notExists("""
SELECT 1
FROM sys_user_role ur
WHERE ur.user_id = sys_user.id
AND ur.deleted = 0
""");
List<UserEntity> users = userMapper.selectList(wrapper);2
3
4
5
6
7
8
9
EXISTS 使用建议:
| 场景 | 建议 |
|---|---|
| 判断关联是否存在 | 可以使用 |
| 查询字段来自子表 | 更适合 JOIN 或 XML |
| SQL 片段复杂 | 推荐 XML |
| 需要传参 | 优先 XML,或使用安全参数绑定方式 |
| 前端传入 SQL | 禁止 |
| 复杂权限过滤 | 推荐 XML 或数据权限插件 |
exists 中的 SQL 片段可读性和安全性都不如 XML,复杂场景不要强行写在 Wrapper 中。
排序查询
排序查询使用 orderByAsc、orderByDesc、orderBy。普通固定排序建议使用 Lambda 字段引用;动态排序必须白名单映射。
固定排序:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.orderByAsc(UserEntity::getSortOrder)
.orderByDesc(UserEntity::getCreateTime);2
3
动态排序不推荐直接这样写:
wrapper.orderByDesc(query.getOrderBy());推荐使用白名单映射:
private static final Map<String, SFunction<UserEntity, ?>> USER_SORT_FIELD_MAP = Map.of(
"createTime", UserEntity::getCreateTime,
"updateTime", UserEntity::getUpdateTime,
"sortOrder", UserEntity::getSortOrder,
"username", UserEntity::getUsername
);2
3
4
5
6
完整示例:
SFunction<UserEntity, ?> sortColumn = USER_SORT_FIELD_MAP.get(query.getOrderBy());
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername());
if (sortColumn != null) {
wrapper.orderBy(true, StrUtil.equalsIgnoreCase(query.getSafeOrderDirection(), "asc"), sortColumn);
} else {
wrapper.orderByDesc(UserEntity::getCreateTime);
}2
3
4
5
6
7
8
9
10
排序查询建议:
| 规范项 | 建议 |
|---|---|
| 默认排序 | 必须设置 |
| 多字段排序 | 常用 sortOrder ASC, createTime DESC |
| 动态字段 | 必须白名单 |
| 动态方向 | 只允许 asc、desc |
| 前端字段 | 使用业务字段,不使用数据库字段 |
| 大表排序 | 排序字段应结合索引设计 |
分组查询
分组查询通常使用 QueryWrapper,因为需要指定 select 聚合字段和 groupBy 字段。返回结果可以使用 VO 或 Map。
统计用户状态数量示例:
文件位置:src/main/java/io/github/atengk/module/system/user/vo/UserStatusCountVO.java
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
/**
* 用户状态统计返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserStatusCountVO {
/**
* 状态编码
*/
private Integer status;
/**
* 用户数量
*/
private Long userCount;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
使用 QueryWrapper 查询 Map:
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.select("status", "COUNT(1) AS user_count")
.eq("deleted", 0)
.groupBy("status");
List<Map<String, Object>> maps = userMapper.selectMaps(wrapper);2
3
4
5
6
如果需要返回强类型 VO,更推荐写 XML:
<select id="selectUserStatusCount" resultType="io.github.atengk.module.system.user.vo.UserStatusCountVO">
SELECT
status,
COUNT(1) AS user_count
FROM sys_user
WHERE deleted = 0
GROUP BY status
</select>2
3
4
5
6
7
8
分组查询建议:
| 场景 | 建议 |
|---|---|
| 简单分组 | 可使用 QueryWrapper |
| 强类型返回 | 推荐 XML |
| 多字段分组 | 推荐 XML |
| HAVING 条件 | 可用 Wrapper,但复杂时推荐 XML |
| 报表统计 | 推荐 XML |
| 大表统计 | 考虑汇总表或缓存 |
聚合查询
聚合查询包括 COUNT、SUM、AVG、MAX、MIN 等。简单计数可以直接使用 count(),复杂聚合建议使用 XML 或 selectMaps。
查询数量:
long count = this.lambdaQuery()
.eq(UserEntity::getStatus, UserStatusEnum.ENABLED)
.count();2
3
使用 QueryWrapper 查询聚合值:
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.select("COUNT(1) AS total_count", "SUM(balance_amount) AS total_amount")
.eq("deleted", 0)
.eq("status", 1);
Map<String, Object> result = userMapper.selectMaps(wrapper).stream()
.findFirst()
.orElse(Collections.emptyMap());2
3
4
5
6
7
8
更推荐 XML 返回强类型统计对象:
文件位置:src/main/java/io/github/atengk/module/system/user/vo/UserAmountStatisticsVO.java
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 用户金额统计返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserAmountStatisticsVO {
/**
* 用户总数
*/
private Long totalCount;
/**
* 余额合计
*/
private BigDecimal totalAmount;
/**
* 最大余额
*/
private BigDecimal maxAmount;
/**
* 最小余额
*/
private BigDecimal minAmount;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
XML:
<select id="selectUserAmountStatistics" resultType="io.github.atengk.module.system.user.vo.UserAmountStatisticsVO">
SELECT
COUNT(1) AS total_count,
COALESCE(SUM(balance_amount), 0) AS total_amount,
COALESCE(MAX(balance_amount), 0) AS max_amount,
COALESCE(MIN(balance_amount), 0) AS min_amount
FROM sys_user
WHERE deleted = 0
AND status = 1
</select>2
3
4
5
6
7
8
9
10
聚合查询建议:
| 场景 | 建议 |
|---|---|
| 单纯计数 | 使用 count() |
| 简单聚合 | 可用 selectMaps |
| 固定统计接口 | 推荐 XML + VO |
| 金额聚合 | 使用 BigDecimal |
| 空结果 | SQL 中使用 COALESCE |
| 大表统计 | 考虑汇总表、缓存或异步统计 |
嵌套条件查询
嵌套条件用于处理复杂的 AND、OR 组合。常见场景是关键字匹配多个字段,或者多个条件之间需要明确优先级。
示例:用户名或昵称或手机号匹配关键字,同时状态为启用。
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getStatus, UserStatusEnum.ENABLED)
.and(StrUtil.isNotBlank(keyword), item -> item
.like(UserEntity::getUsername, keyword)
.or()
.like(UserEntity::getNickname, keyword)
.or()
.like(UserEntity::getPhone, keyword)
);2
3
4
5
6
7
8
9
生成逻辑类似:
WHERE status = 1
AND (
username LIKE '%keyword%'
OR nickname LIKE '%keyword%'
OR phone LIKE '%keyword%'
)2
3
4
5
6
示例:状态为启用,且创建时间在范围内,或者用户是管理员。
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.and(item -> item
.eq(UserEntity::getStatus, UserStatusEnum.ENABLED)
.ge(UserEntity::getCreateTime, startTime)
.le(UserEntity::getCreateTime, endTime)
)
.or(item -> item
.eq(UserEntity::getUsername, "admin")
);2
3
4
5
6
7
8
9
嵌套条件建议:
| 规范项 | 建议 |
|---|---|
| 多个 OR | 使用 and(item -> item.xxx().or().xxx()) 包裹 |
| 条件优先级 | 明确使用嵌套,避免 SQL 语义错误 |
| 可读性 | 超过三层嵌套建议改用 XML |
| 关键字搜索 | 多字段 OR 必须加括号 |
| 权限条件 | 谨慎与 OR 混用,避免权限绕过 |
| 复杂场景 | 优先 XML |
错误示例:
wrapper.eq(UserEntity::getStatus, UserStatusEnum.ENABLED)
.like(UserEntity::getUsername, keyword)
.or()
.like(UserEntity::getPhone, keyword);2
3
4
这类写法容易变成:
WHERE status = 1
AND username LIKE '%keyword%'
OR phone LIKE '%keyword%'2
3
由于 OR 优先级问题,可能导致状态条件失效。多字段关键字搜索必须使用嵌套条件。
条件构造器复用
条件构造器复用用于避免列表查询、分页查询、导出查询重复拼接相同条件。推荐把 Wrapper 构建逻辑封装为私有方法,或者放到专门的查询构建器类中。不要把 Wrapper 作为全局静态变量复用,因为 Wrapper 是可变对象,不是线程安全的复用组件。
推荐 Service 内部私有方法复用:
private LambdaQueryWrapper<UserEntity> buildUserQueryWrapper(UserPageQuery query) {
return new LambdaQueryWrapper<UserEntity>()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime);
}2
3
4
5
6
7
8
9
如果多个 Service 都需要复用,可以建立独立 Builder。
文件位置:src/main/java/io/github/atengk/module/system/user/support/UserWrapperBuilder.java
package io.github.atengk.module.system.user.support;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.enums.UserStatusEnum;
import io.github.atengk.module.system.user.query.UserPageQuery;
import org.springframework.stereotype.Component;
/**
* 用户条件构造器构建器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
public class UserWrapperBuilder {
/**
* 构建用户分页查询条件
*
* @param query 用户分页查询参数
* @return 用户查询条件构造器
*/
public LambdaQueryWrapper<UserEntity> buildPageQuery(UserPageQuery query) {
return new LambdaQueryWrapper<UserEntity>()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime);
}
/**
* 构建用户启用状态查询条件
*
* @return 用户查询条件构造器
*/
public LambdaQueryWrapper<UserEntity> buildEnabledQuery() {
return new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getStatus, UserStatusEnum.ENABLED)
.orderByAsc(UserEntity::getSortOrder)
.orderByDesc(UserEntity::getCreateTime);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Service 使用示例:
List<UserEntity> users = this.list(userWrapperBuilder.buildPageQuery(query));条件构造器复用注意事项:
| 规范项 | 建议 |
|---|---|
| Wrapper 实例 | 每次新建,不要静态复用 |
| 构建方法 | 无副作用,只负责拼接条件 |
| 通用条件 | 可抽取私有方法或 Builder |
| 业务规则 | 不要全部塞进 Builder,复杂规则仍放 Service |
| 多模块复用 | 不建议跨业务模块共享大量 Wrapper |
| 复杂 SQL | 不要为了复用 Wrapper 放弃 XML 可读性 |
Wrapper 的最佳使用边界是单表、常规动态条件、轻量统计。只要 SQL 变得难读、嵌套过深、需要复杂 JOIN 或报表统计,就应改用 XML 自定义 SQL,避免把 Wrapper 写成难维护的“Java 版 SQL 字符串”。
分页查询
分页查询用于解决列表数据量过大的问题,是后台管理系统、业务查询接口、数据维护页面中最常见的查询方式。MyBatis-Plus 提供了 Page、IPage 和分页拦截器,可以对普通单表查询、自定义 Mapper 查询、XML SQL 查询进行自动分页处理。分页查询不仅要关注功能实现,还要关注分页参数保护、最大分页限制、COUNT 查询性能和深分页优化。
PaginationInnerInterceptor 配置
PaginationInnerInterceptor 是 MyBatis-Plus 的分页内置拦截器。Spring Boot 项目中需要通过 MybatisPlusInterceptor 注册分页插件。官方分页插件文档说明,PaginationInnerInterceptor 支持 overflow、maxLimit、dbType、dialect 等属性;其中 maxLimit 用于限制单页分页条数,overflow 用于控制页码超过最大页时的处理方式。(MyBatis-Plus)
如果使用 MyBatis-Plus 3.5.9 或更高版本,分页插件相关 SQL 解析能力需要单独引入 mybatis-plus-jsqlparser。前文依赖配置中已经给出,这里再次列出关键依赖。
文件位置:pom.xml
<!-- MyBatis-Plus Spring Boot 3 集成 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MyBatis-Plus 分页插件在 3.5.9+ 后需要单独引入 SQL 解析模块 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>2
3
4
5
6
7
8
9
10
11
12
13
分页插件配置类如下。
文件位置:src/main/java/io/github/atengk/framework/mybatis/MyBatisPlusConfig.java
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 配置
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Configuration
public class MyBatisPlusConfig {
/**
* 配置 MyBatis-Plus 拦截器
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setOverflow(false);
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
log.info("MyBatis-Plus分页插件初始化完成,数据库类型:MYSQL,单页最大条数:{}", 200);
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
如果项目同时使用多租户、动态表名、乐观锁、分页、防全表更新等多个插件,需要注意插件顺序。MyBatis-Plus 插件文档建议,多租户和动态表名这类会改造 SQL 的插件优先添加,分页和乐观锁在其后,SQL 规范和防全表更新类插件放在后面。(MyBatis-Plus)
示例:
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 示例:多租户、动态表名等会改写 SQL 的插件应优先添加
// interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(...));
// interceptor.addInnerInterceptor(new DynamicTableNameInnerInterceptor(...));
// 分页插件放在后面
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
return interceptor;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
分页插件常用属性如下:
| 属性 | 类型 | 建议值 | 说明 |
|---|---|---|---|
dbType | DbType | MYSQL | 单一数据库项目建议显式指定 |
overflow | boolean | false | 页码超过最大页时是否处理 |
maxLimit | Long | 100 或 200 | 单页最大条数 |
dialect | IDialect | 一般不配置 | 自定义数据库方言时使用 |
普通后台管理系统建议将单页最大条数控制在 100 到 200。导出接口不应通过超大 pageSize 规避分页限制,而应走专门的导出逻辑。
Page 对象使用
Page<T> 是 MyBatis-Plus 提供的分页模型,继承自 IPage<T>。官方文档说明,Page 包含 records、total、size、current、orders、optimizeCountSql、searchCount、maxLimit、countId 等属性。(MyBatis-Plus)
基础用法如下:
Page<UserEntity> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserEntity> result = userService.lambdaQuery()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.orderByDesc(UserEntity::getCreateTime)
.page(page);2
3
4
5
6
7
常用属性说明:
| 属性 | 说明 |
|---|---|
current | 当前页码 |
size | 每页条数 |
total | 总记录数 |
records | 当前页数据 |
pages | 总页数 |
searchCount | 是否执行 COUNT 查询 |
optimizeCountSql | 是否优化 COUNT SQL |
maxLimit | 当前分页对象的最大条数限制 |
orders | 排序字段 |
禁用 COUNT 查询的场景:
Page<UserEntity> page = Page.of(query.getPageNum(), query.getPageSize());
page.setSearchCount(false);2
禁用 COUNT 查询适合“只需要下一页数据,不需要总数”的场景,例如消息列表、日志滚动加载、移动端加载更多。后台管理系统列表页通常需要展示总数,因此一般保留 COUNT 查询。
使用排序项:
Page<UserEntity> page = Page.of(query.getPageNum(), query.getPageSize());
page.addOrder(OrderItem.desc("create_time"));
page.addOrder(OrderItem.asc("sort_order"));2
3
如果排序字段来自前端,必须先做白名单映射,不允许直接把前端字段透传给 OrderItem。
IPage 返回处理
IPage<T> 是分页接口,Page<T> 是其常用实现。自定义 Mapper 方法可以返回 IPage<T>、Page<T> 或 List<T>。MyBatis-Plus 分页文档说明,如果自定义 Mapper 返回类型是 IPage,入参的 IPage 不能为 null;如果返回 List,入参 IPage 可以为 null,但需要手动将返回列表设置到分页对象中。(MyBatis-Plus)
推荐返回 Page<VO>:
/**
* 分页查询用户
*
* @param page 分页对象
* @param query 查询参数
* @return 用户分页结果
*/
Page<UserPageVO> selectUserPage(Page<UserPageVO> page, @Param("query") UserPageQuery query);2
3
4
5
6
7
8
Service 处理:
Page<UserPageVO> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserPageVO> result = userMapper.selectUserPage(page, query);
return PageResult.of(
result.getCurrent(),
result.getSize(),
result.getTotal(),
result.getRecords()
);2
3
4
5
6
7
8
9
如果 Mapper 返回 List<VO>:
/**
* 分页查询用户列表
*
* @param page 分页对象
* @param query 查询参数
* @return 用户列表
*/
List<UserPageVO> selectUserPageList(Page<UserPageVO> page, @Param("query") UserPageQuery query);2
3
4
5
6
7
8
Service 处理:
Page<UserPageVO> page = Page.of(query.getPageNum(), query.getPageSize());
List<UserPageVO> records = userMapper.selectUserPageList(page, query);
page.setRecords(records);
return PageResult.of(
page.getCurrent(),
page.getSize(),
page.getTotal(),
page.getRecords()
);2
3
4
5
6
7
8
9
10
推荐统一在 Service 层把 IPage 转换成项目自己的 PageResult<T>,避免 Controller 和前端直接依赖 MyBatis-Plus 的分页模型。
通用分页请求对象
通用分页请求对象用于统一页码、页大小、排序字段和排序方向。分页参数必须有默认值和上限,避免前端传入超大 pageSize 造成数据库压力。
文件位置:src/main/java/io/github/atengk/common/query/PageQuery.java
package io.github.atengk.common.query;
import cn.hutool.core.util.StrUtil;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.Setter;
import java.util.Set;
/**
* 通用分页查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class PageQuery {
/**
* 当前页
*/
@Min(value = 1, message = "当前页不能小于1")
private Long pageNum = 1L;
/**
* 每页条数
*/
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 200, message = "每页条数不能超过200")
private Long pageSize = 10L;
/**
* 排序字段
*/
private String orderBy;
/**
* 排序方向:asc 或 desc
*/
private String orderDirection = "desc";
/**
* 获取安全排序方向
*
* @return 排序方向
*/
public String getSafeOrderDirection() {
return StrUtil.equalsIgnoreCase(orderDirection, "asc") ? "asc" : "desc";
}
/**
* 判断排序字段是否允许使用
*
* @param allowedFields 允许排序字段集合
* @return 是否允许
*/
public boolean isAllowedOrderBy(Set<String> allowedFields) {
return StrUtil.isNotBlank(orderBy) && allowedFields.contains(orderBy);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
业务分页对象继承示例:
文件位置:src/main/java/io/github/atengk/module/system/user/query/UserPageQuery.java
package io.github.atengk.module.system.user.query;
import io.github.atengk.common.query.PageQuery;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户分页查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserPageQuery extends PageQuery {
/**
* 用户名
*/
@Size(max = 64, message = "用户名长度不能超过64个字符")
private String username;
/**
* 手机号
*/
@Size(max = 20, message = "手机号长度不能超过20个字符")
private String phone;
/**
* 状态:0禁用,1启用
*/
private Integer status;
/**
* 创建开始时间
*/
private LocalDateTime startTime;
/**
* 创建结束时间
*/
private LocalDateTime endTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
分页请求对象建议只承载查询参数,不放业务计算逻辑。排序白名单、租户条件、权限条件应在 Service 或专门的查询构建器中处理。
通用分页响应对象
通用分页响应对象用于屏蔽 MyBatis-Plus 的 IPage 细节,对外统一返回 pageNum、pageSize、total、pages、records 等字段。
文件位置:src/main/java/io/github/atengk/common/vo/PageResult.java
package io.github.atengk.common.vo;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
/**
* 通用分页返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class PageResult<T> implements Serializable {
/**
* 当前页
*/
private Long pageNum;
/**
* 每页条数
*/
private Long pageSize;
/**
* 总条数
*/
private Long total;
/**
* 总页数
*/
private Long pages;
/**
* 数据列表
*/
private List<T> records;
/**
* 创建分页返回对象
*
* @param pageNum 当前页
* @param pageSize 每页条数
* @param total 总条数
* @param records 数据列表
* @param <T> 数据类型
* @return 分页返回对象
*/
public static <T> PageResult<T> of(Long pageNum, Long pageSize, Long total, List<T> records) {
PageResult<T> result = new PageResult<>();
result.setPageNum(pageNum);
result.setPageSize(pageSize);
result.setTotal(total);
result.setPages(calculatePages(total, pageSize));
result.setRecords(records == null ? Collections.emptyList() : records);
return result;
}
/**
* 从 MyBatis-Plus 分页对象创建分页返回对象
*
* @param page MyBatis-Plus 分页对象
* @param <T> 数据类型
* @return 分页返回对象
*/
public static <T> PageResult<T> of(IPage<T> page) {
return of(page.getCurrent(), page.getSize(), page.getTotal(), page.getRecords());
}
/**
* 计算总页数
*
* @param total 总条数
* @param pageSize 每页条数
* @return 总页数
*/
private static Long calculatePages(Long total, Long pageSize) {
if (total == null || pageSize == null || pageSize <= 0) {
return 0L;
}
return (total + pageSize - 1) / pageSize;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
统一分页响应示例:
{
"code": 200,
"message": "操作成功",
"data": {
"pageNum": 1,
"pageSize": 10,
"total": 35,
"pages": 4,
"records": []
},
"success": true,
"timestamp": "2026-05-05T10:00:00"
}2
3
4
5
6
7
8
9
10
11
12
13
分页响应对象不建议直接返回 MyBatis-Plus 的 Page,因为其中包含较多框架内部字段,不利于接口稳定性。
单表分页
单表分页适合使用 lambdaQuery().page(page) 或 BaseMapper.selectPage(page, wrapper)。普通后台管理列表页优先使用 Lambda Wrapper,字段引用更安全。
Service 示例:
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserPageService.java
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.system.user.convert.UserConvert;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.enums.UserStatusEnum;
import io.github.atengk.module.system.user.mapper.UserMapper;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.vo.UserPageVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
/**
* 用户分页查询服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserPageService extends ServiceImpl<UserMapper, UserEntity> {
private final UserConvert userConvert;
/**
* 单表分页查询用户
*
* @param query 用户分页查询参数
* @return 用户分页结果
*/
public PageResult<UserPageVO> pageUser(UserPageQuery query) {
Page<UserEntity> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserEntity> result = this.lambdaQuery()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime)
.page(page);
List<UserPageVO> records = CollUtil.isEmpty(result.getRecords())
? Collections.emptyList()
: userConvert.toPageVOList(result.getRecords());
log.info("单表分页查询用户完成,页码:{},每页条数:{},总数:{}",
query.getPageNum(), query.getPageSize(), result.getTotal());
return PageResult.of(result.getCurrent(), result.getSize(), result.getTotal(), records);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
单表分页适合的场景:
| 场景 | 建议 |
|---|---|
| 用户列表 | 适合 |
| 角色列表 | 适合 |
| 字典列表 | 适合 |
| 订单列表基础字段 | 适合 |
| 操作日志列表 | 适合,但要关注深分页 |
| 报表统计列表 | 不一定适合,通常使用 XML |
单表分页中,如果列表只需要部分字段,可以使用 select 指定字段,减少数据库和网络开销。
多条件分页
多条件分页是在单表分页基础上增加更多筛选条件,例如关键字、状态、时间范围、租户、部门、排序等。建议将条件构建抽取为私有方法或查询构建器,避免分页、列表、导出重复拼接条件。
示例:
private LambdaQueryWrapper<UserEntity> buildUserPageWrapper(UserPageQuery query) {
return new LambdaQueryWrapper<UserEntity>()
.and(StrUtil.isNotBlank(query.getUsername()), item -> item
.like(UserEntity::getUsername, query.getUsername())
.or()
.like(UserEntity::getNickname, query.getUsername())
)
.eq(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, UserStatusEnum.ofCode(query.getStatus()))
.ge(ObjectUtil.isNotNull(query.getStartTime()), UserEntity::getCreateTime, query.getStartTime())
.le(ObjectUtil.isNotNull(query.getEndTime()), UserEntity::getCreateTime, query.getEndTime())
.orderByDesc(UserEntity::getCreateTime);
}2
3
4
5
6
7
8
9
10
11
12
13
分页使用:
Page<UserEntity> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserEntity> result = userMapper.selectPage(page, buildUserPageWrapper(query));2
多条件分页注意事项:
| 条件类型 | 建议 |
|---|---|
| 关键字 | 控制长度,避免大范围模糊查询 |
| 状态 | 使用枚举映射 |
| 时间范围 | 明确是否包含结束时间 |
| 租户 | 从上下文获取,不信任前端传入 |
| 排序 | 白名单映射 |
| 数据权限 | 与查询条件统一组装 |
| 大表 | 结合索引评估执行计划 |
多条件分页要避免“所有字段都支持模糊查询”。常见做法是开放少量高价值查询条件,并为高频条件设计索引。
多表关联分页
多表关联分页适合放在 XML 中编写。常见场景包括用户列表显示部门名称、订单列表显示客户名称、商品列表显示分类名称等。多表分页时要特别关注 COUNT SQL 的准确性和性能,尤其是一对多 JOIN 可能导致主表记录重复。
Mapper 方法:
文件位置:src/main/java/io/github/atengk/module/system/user/mapper/UserMapper.java
package io.github.atengk.module.system.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.vo.UserPageVO;
import org.apache.ibatis.annotations.Param;
/**
* 用户 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserMapper extends BaseMapper<UserEntity> {
/**
* 多表关联分页查询用户
*
* @param page 分页对象
* @param query 查询参数
* @return 用户分页结果
*/
Page<UserPageVO> selectUserJoinPage(Page<UserPageVO> page, @Param("query") UserPageQuery query);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
XML 示例:
文件位置:src/main/resources/mapper/system/UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.atengk.module.system.user.mapper.UserMapper">
<!-- 多表关联分页查询用户 -->
<select id="selectUserJoinPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
u.id,
u.username,
u.nickname,
u.phone,
u.status,
u.create_time,
d.dept_name
FROM sys_user u
LEFT JOIN sys_dept d ON d.id = u.dept_id AND d.deleted = 0
<where>
u.deleted = 0
<if test="query.username != null and query.username != ''">
AND (
u.username LIKE CONCAT('%', #{query.username}, '%')
OR u.nickname LIKE CONCAT('%', #{query.username}, '%')
)
</if>
<if test="query.phone != null and query.phone != ''">
AND u.phone = #{query.phone}
</if>
<if test="query.status != null">
AND u.status = #{query.status}
</if>
<if test="query.startTime != null">
AND u.create_time >= #{query.startTime}
</if>
<if test="query.endTime != null">
AND u.create_time <= #{query.endTime}
</if>
</where>
ORDER BY u.create_time DESC
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Service 调用:
public PageResult<UserPageVO> pageUserJoin(UserPageQuery query) {
Page<UserPageVO> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserPageVO> result = userMapper.selectUserJoinPage(page, query);
return PageResult.of(result);
}2
3
4
5
MyBatis-Plus 分页插件文档提醒,在 COUNT SQL 优化时,如果 left join 的表没有参与 where 条件,可能会被优化移除;涉及 left join 的 SQL 建议始终为表和字段使用别名。(MyBatis-Plus)
多表关联分页建议:
| 场景 | 建议 |
|---|---|
| 一对一 JOIN | 可以直接分页 |
| 一对多 JOIN | 谨慎,可能导致主表重复 |
| 复杂关联 | 先分页主表 ID,再查询关联数据 |
| COUNT 性能差 | 自定义 COUNT SQL |
| 查询字段 | 不要 SELECT * |
| 表别名 | 必须统一使用 |
| 数据权限 | 明确作用于主表还是关联表 |
自定义 SQL 分页
自定义 SQL 分页指 Mapper XML 中写普通查询 SQL,由 MyBatis-Plus 分页插件自动添加分页语句和 COUNT 查询。官方分页文档说明,在自定义 Mapper 方法中可以通过 IPage 或 Page 参数使用分页,对应 XML 就像普通列表查询一样编写。(MyBatis-Plus)
Mapper 方法:
/**
* 自定义 SQL 分页查询用户
*
* @param page 分页对象
* @param query 查询参数
* @return 用户分页结果
*/
Page<UserPageVO> selectCustomUserPage(Page<UserPageVO> page, @Param("query") UserPageQuery query);2
3
4
5
6
7
8
XML:
<select id="selectCustomUserPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
u.id,
u.username,
u.nickname,
u.phone,
u.status,
u.create_time
FROM sys_user u
<where>
u.deleted = 0
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.status != null">
AND u.status = #{query.status}
</if>
</where>
ORDER BY u.create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
如果自动 COUNT SQL 性能差,可以使用 countId 指定自定义 COUNT 语句。
Mapper XML:
<select id="selectCustomUserPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
u.id,
u.username,
u.nickname,
u.phone,
u.status,
u.create_time
FROM sys_user u
WHERE u.deleted = 0
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
ORDER BY u.create_time DESC
</select>
<select id="selectCustomUserPageCount" resultType="java.lang.Long">
SELECT
COUNT(1)
FROM sys_user u
WHERE u.deleted = 0
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Service 中设置 countId:
Page<UserPageVO> page = Page.of(query.getPageNum(), query.getPageSize());
page.setCountId("selectCustomUserPageCount");
Page<UserPageVO> result = userMapper.selectCustomUserPage(page, query);
return PageResult.of(result);2
3
4
5
自定义 SQL 分页注意事项:
| 规范项 | 建议 |
|---|---|
| XML SQL | 不要手写 LIMIT |
| 参数 | 使用 @Param("query") |
| 排序 | SQL 中明确排序 |
| COUNT 慢 | 使用自定义 countId |
| 多表 JOIN | 注意 COUNT 优化和重复行 |
| 返回对象 | 推荐返回 VO,不直接返回 Entity |
| 分页参数 | Page 参数必须传入 |
分页参数保护
分页参数保护用于防止前端传入异常页码、超大页大小、非法排序字段和恶意排序方向。分页参数保护应同时在 DTO 校验、分页插件 maxLimit 和 Service 白名单中处理。
Controller 参数校验:
@GetMapping("/page")
public ApiResult<PageResult<UserPageVO>> pageUser(@Valid UserPageQuery query) {
PageResult<UserPageVO> pageResult = userService.pageUser(query);
return ApiResult.success(pageResult);
}2
3
4
5
PageQuery 中限制:
@Min(value = 1, message = "当前页不能小于1")
private Long pageNum = 1L;
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 200, message = "每页条数不能超过200")
private Long pageSize = 10L;2
3
4
5
6
Service 中排序白名单:
private static final Map<String, SFunction<UserEntity, ?>> SORT_FIELD_MAP = Map.of(
"createTime", UserEntity::getCreateTime,
"updateTime", UserEntity::getUpdateTime,
"sortOrder", UserEntity::getSortOrder,
"username", UserEntity::getUsername
);2
3
4
5
6
排序处理:
private void applyOrder(UserPageQuery query, LambdaQueryWrapper<UserEntity> wrapper) {
SFunction<UserEntity, ?> sortColumn = SORT_FIELD_MAP.get(query.getOrderBy());
if (sortColumn == null) {
wrapper.orderByDesc(UserEntity::getCreateTime);
return;
}
boolean asc = StrUtil.equalsIgnoreCase(query.getSafeOrderDirection(), "asc");
wrapper.orderBy(true, asc, sortColumn);
}2
3
4
5
6
7
8
9
10
11
分页参数保护建议:
| 保护项 | 建议 |
|---|---|
pageNum | 最小值为 1 |
pageSize | 设置默认值和最大值 |
maxLimit | 分页插件兜底限制 |
orderBy | 白名单映射 |
orderDirection | 只允许 asc、desc |
| 空条件分页 | 大表中谨慎开放 |
| 导出接口 | 不允许通过超大分页导出 |
分页保护不能只依赖前端。后端必须对分页参数做硬限制。
最大分页限制
最大分页限制用于防止单次查询过多数据。建议同时使用 Validation 和 PaginationInnerInterceptor#setMaxLimit。前者用于给前端明确提示,后者用于底层兜底。maxLimit 是分页插件提供的单页分页条数限制属性。(MyBatis-Plus)
配置示例:
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);2
请求参数限制:
@Max(value = 200, message = "每页条数不能超过200")
private Long pageSize = 10L;2
不同接口建议限制如下:
| 接口类型 | 建议最大值 |
|---|---|
| 后台管理普通列表 | 100 或 200 |
| 下拉选项 | 500,但要谨慎 |
| 日志列表 | 100 |
| 订单列表 | 100 |
| 移动端加载更多 | 20 或 50 |
| 导出接口 | 不走普通分页最大限制,应单独限制导出总量 |
不要把 pageSize 最大值设置成几万。大数据导出应使用专门导出流程,必要时异步生成文件。
深分页优化
深分页指页码很大时的分页查询,例如 pageNum=100000&pageSize=20。在 MySQL 中,传统 LIMIT offset, size 会扫描并跳过大量数据,offset 越大性能越差。MyBatis-Plus 自动分页本质上仍依赖数据库分页能力,因此深分页需要单独优化。
常见深分页问题:
SELECT id, username, create_time
FROM sys_user
WHERE deleted = 0
ORDER BY create_time DESC
LIMIT 2000000, 20;2
3
4
5
优化方向一:基于游标分页。使用上一页最后一条记录的排序字段作为游标。
请求参数示例:
lastCreateTime=2026-05-05 10:00:00
pageSize=202
SQL 逻辑:
SELECT id, username, create_time
FROM sys_user
WHERE deleted = 0
AND create_time < #{lastCreateTime}
ORDER BY create_time DESC
LIMIT #{pageSize}2
3
4
5
6
Wrapper 示例:
public List<UserPageVO> scrollUser(LocalDateTime lastCreateTime, Long pageSize) {
long safeSize = Math.min(ObjectUtil.defaultIfNull(pageSize, 20L), 100L);
List<UserEntity> records = this.lambdaQuery()
.lt(ObjectUtil.isNotNull(lastCreateTime), UserEntity::getCreateTime, lastCreateTime)
.orderByDesc(UserEntity::getCreateTime)
.last("LIMIT " + safeSize)
.list();
return CollUtil.isEmpty(records) ? Collections.emptyList() : userConvert.toPageVOList(records);
}2
3
4
5
6
7
8
9
10
11
这里使用 last 时,safeSize 必须由后端控制,不能直接拼接前端原始参数。更推荐在 XML 中使用 LIMIT #{pageSize},减少 SQL 拼接风险。
优化方向二:先查 ID,再回表查询。
SELECT u.id, u.username, u.nickname, u.create_time
FROM sys_user u
INNER JOIN (
SELECT id
FROM sys_user
WHERE deleted = 0
ORDER BY create_time DESC
LIMIT 2000000, 20
) t ON t.id = u.id
ORDER BY u.create_time DESC;2
3
4
5
6
7
8
9
10
这种方式适合列表字段较多、主表较宽的场景,但 offset 很大时仍然有成本,只是减少了回表数据量。
优化方向三:限制最大可访问页数。
private void checkDeepPage(UserPageQuery query) {
long maxOffset = 10000L;
long offset = (query.getPageNum() - 1) * query.getPageSize();
if (offset > maxOffset) {
throw new BusinessException("查询页数过深,请缩小筛选条件后重试");
}
}2
3
4
5
6
7
8
优化方向四:使用搜索引擎或专门查询模型。日志、消息、流水、审计记录等大数据量查询,适合按时间倒序滚动加载,而不是传统页码分页。
深分页优化建议:
| 场景 | 建议 |
|---|---|
| 后台普通列表 | 限制最大页码或最大 offset |
| 日志列表 | 使用游标分页或时间范围查询 |
| 消息列表 | 使用游标分页 |
| 订单历史 | 强制时间范围筛选 |
| 大表导出 | 异步导出,不走普通分页 |
| 复杂报表 | 使用汇总表、宽表或 OLAP 引擎 |
| 移动端加载更多 | 游标分页优先 |
普通管理系统可以先使用 MyBatis-Plus 默认分页;当表数据量达到百万级、千万级,或者用户经常访问深页码时,应尽早改造为游标分页、时间范围查询、ID 子查询或异步导出方案。
CRUD 常用场景
CRUD 是 MyBatis-Plus 最常用的能力,适合处理单表新增、修改、删除、查询、统计、存在性判断、保存或更新、批量保存或更新、局部字段更新等场景。普通单表操作优先使用 IService、ServiceImpl 和 Lambda Wrapper;复杂 SQL、多表关联、复杂统计仍建议使用 XML。
本章节示例代码默认放在 Service 层,Controller 不直接操作 Mapper,Mapper 不承载业务规则。
单条新增
单条新增适合普通表单创建场景。新增时通常需要完成参数校验、唯一性校验、默认值处理、DTO 转 Entity、保存数据和日志记录。
推荐使用 save(entity):
@Transactional(rollbackFor = Exception.class)
public Long addUser(UserAddDTO dto) {
this.checkUsernameUnique(dto.getUsername(), null);
this.checkPhoneUnique(dto.getPhone(), null);
UserEntity entity = userConvert.toEntity(dto);
boolean saved = this.save(entity);
if (!saved) {
throw new BusinessException("新增用户失败");
}
log.info("新增用户成功,用户ID:{},用户名:{}", entity.getId(), entity.getUsername());
return entity.getId();
}2
3
4
5
6
7
8
9
10
11
12
13
14
单条新增注意事项:
| 检查项 | 建议 |
|---|---|
| 唯一字段 | 用户名、手机号、编码等先做业务校验,再依赖数据库唯一索引兜底 |
| 主键策略 | ASSIGN_ID 新增前后都可获得 ID,AUTO 插入后回填 ID |
| 审计字段 | 创建人、创建时间建议使用自动填充 |
| 默认值 | 状态、排序、金额等可以在 Service 层补充 |
| 事务 | 单表新增可不显式加事务,多表新增必须加事务 |
| 日志 | 记录业务关键字段,不记录密码、Token、密钥 |
新增接口不要直接保存前端传来的 Entity。应使用 AddDTO 接收入参,再转换为 Entity,避免前端控制主键、逻辑删除、乐观锁、审计字段等内部字段。
批量新增
批量新增适合 Excel 导入、初始化数据、批量创建配置项等场景。MyBatis-Plus 提供 saveBatch(Collection<T> entityList) 和 saveBatch(Collection<T> entityList, int batchSize)。
推荐指定批次大小:
@Transactional(rollbackFor = Exception.class)
public List<Long> batchAddUser(List<UserAddDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return List.of();
}
List<UserEntity> entities = dtoList.stream()
.peek(dto -> {
this.checkUsernameUnique(dto.getUsername(), null);
this.checkPhoneUnique(dto.getPhone(), null);
})
.map(userConvert::toEntity)
.toList();
boolean saved = this.saveBatch(entities, 1000);
if (!saved) {
throw new BusinessException("批量新增用户失败");
}
log.info("批量新增用户成功,数量:{}", entities.size());
return entities.stream().map(UserEntity::getId).toList();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
批量新增注意事项:
| 场景 | 建议 |
|---|---|
| 小批量 | 可以直接 saveBatch |
| 大批量 | 按 500 到 2000 条分批,具体按压测调整 |
| 导入数据 | 先做文件内重复校验,再做数据库重复校验 |
| 事务范围 | 大文件导入不建议一个事务覆盖全部数据 |
| 失败处理 | 导入场景建议返回失败明细 |
| 唯一约束 | 数据库唯一索引必须保留 |
批量新增中不要逐条调用 save,否则会产生大量数据库往返。应优先使用 saveBatch 或 XML 批量插入。
根据 ID 修改
根据 ID 修改适合普通详情页保存。推荐先查询原数据,再校验业务规则,最后合并字段并调用 updateById(entity)。
@Transactional(rollbackFor = Exception.class)
public void updateUser(UserUpdateDTO dto) {
UserEntity entity = this.getRequiredById(dto.getId());
if (StrUtil.isNotBlank(dto.getPhone())) {
this.checkPhoneUnique(dto.getPhone(), dto.getId());
}
userConvert.updateEntity(dto, entity);
boolean updated = this.updateById(entity);
if (!updated) {
throw new BusinessException("用户修改失败,请刷新后重试");
}
log.info("根据ID修改用户成功,用户ID:{}", dto.getId());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
根据 ID 修改注意事项:
| 检查项 | 建议 |
|---|---|
| 数据存在性 | 修改前必须查询 |
| 唯一字段 | 修改时排除当前 ID |
| 空字段 | 明确是忽略空字段还是清空字段 |
| 乐观锁 | 并发敏感场景建议携带 version |
| 自动填充 | updateById 可触发更新字段自动填充 |
| 敏感字段 | 密码、余额、权限字段建议使用专门接口修改 |
不建议直接 BeanUtil.copyProperties(dto, entity) 后无脑更新,尤其是在 DTO 字段允许为空时,容易把原有字段覆盖成 null。
根据条件修改
根据条件修改适合批量修改状态、批量调整标识、按业务条件关闭数据等场景。推荐优先使用 LambdaUpdateWrapper。
@Transactional(rollbackFor = Exception.class)
public void disableUserByIds(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return;
}
boolean updated = this.lambdaUpdate()
.in(UserEntity::getId, ids)
.set(UserEntity::getStatus, 0)
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("批量禁用用户失败");
}
log.info("根据条件修改用户状态成功,数量:{}", ids.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
根据条件修改注意事项:
| 风险点 | 建议 |
|---|---|
| 无条件更新 | 必须避免,禁止没有 WHERE 条件的更新 |
| 条件过宽 | 更新前确认影响范围 |
| 自动填充 | 使用 Wrapper 更新时,必要字段可手动 set |
| 乐观锁 | 条件更新可能不适合复杂乐观锁场景 |
| 日志 | 记录影响条件和数量 |
| 大批量 | 大批量更新要评估锁表、事务时间和索引命中 |
条件更新适合修改相同字段值。如果每条数据要更新不同字段值,通常使用 updateBatchById 更合适。
根据 ID 删除
根据 ID 删除通常执行逻辑删除。如果实体配置了 @TableLogic 或全局逻辑删除字段,removeById(id) 会按逻辑删除规则执行。
@Transactional(rollbackFor = Exception.class)
public void deleteUser(Long id) {
UserEntity entity = this.getRequiredById(id);
boolean removed = this.removeById(entity.getId());
if (!removed) {
throw new BusinessException("用户删除失败,请刷新后重试");
}
log.info("根据ID删除用户成功,用户ID:{}", id);
}2
3
4
5
6
7
8
9
10
11
根据 ID 删除注意事项:
| 检查项 | 建议 |
|---|---|
| 数据是否存在 | 删除前查询 |
| 是否可删除 | 系统内置数据、被引用数据不能直接删 |
| 删除方式 | 核心业务表优先逻辑删除 |
| 关联关系 | 删除前校验是否有关联数据 |
| 操作日志 | 删除属于敏感操作,应记录 |
| 物理删除 | 仅用于临时表、中间表、缓存表等场景 |
不要把删除接口做成“传 ID 就删”。核心数据必须先判断是否允许删除。
根据条件删除
根据条件删除适合清理临时数据、删除某个业务范围内的数据、批量逻辑删除等场景。推荐使用 LambdaQueryWrapper 或 LambdaUpdateWrapper,并且必须确保条件明确。
@Transactional(rollbackFor = Exception.class)
public void deleteDisabledUsersBefore(LocalDateTime beforeTime) {
if (beforeTime == null) {
throw new BusinessException("清理时间不能为空");
}
boolean removed = this.remove(new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getStatus, 0)
.lt(UserEntity::getCreateTime, beforeTime));
if (!removed) {
log.info("没有需要删除的禁用用户,清理时间:{}", beforeTime);
return;
}
log.info("根据条件删除禁用用户完成,清理时间:{}", beforeTime);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
根据条件删除注意事项:
| 风险点 | 建议 |
|---|---|
| 空条件 | 禁止执行 |
| 条件过宽 | 删除前可先统计数量 |
| 大批量删除 | 分批执行,避免大事务 |
| 逻辑删除 | 普通业务表优先逻辑删除 |
| 物理删除 | 仅用于可清理数据 |
| 审计 | 记录删除条件、操作人和数量 |
如果是清理历史数据,不建议由普通接口触发,通常应放到定时任务或后台运维任务中。
根据 ID 查询
根据 ID 查询适合详情接口、修改前回显、业务校验等场景。推荐封装一个 getRequiredById 方法,避免到处重复判断空值。
public UserDetailVO getUserDetail(Long id) {
UserEntity entity = this.getRequiredById(id);
return userConvert.toDetailVO(entity);
}
private UserEntity getRequiredById(Long id) {
if (id == null) {
throw new BusinessException("用户ID不能为空");
}
UserEntity entity = this.getById(id);
if (entity == null) {
throw new BusinessException(ResultCode.DATA_NOT_FOUND, "用户不存在");
}
return entity;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
根据 ID 查询注意事项:
| 场景 | 建议 |
|---|---|
| 详情接口 | 返回 VO,不直接返回 Entity |
| 修改前查询 | 查询 Entity 后再合并字段 |
| 删除前查询 | 用于判断是否存在和是否允许删除 |
| 数据权限 | 查询后校验或查询时带权限条件 |
| 敏感字段 | 不返回密码、密钥、Token |
| 逻辑删除 | 默认不返回已逻辑删除数据 |
getById 返回 null 是正常情况,不应直接继续使用对象,避免空指针异常。
根据条件查询单条
根据条件查询单条常用于查询唯一字段,例如用户名、手机号、业务编码、订单号等。推荐使用 one() 或 getOne(wrapper, false),同时保证数据库唯一索引。
public UserEntity getByUsername(String username) {
if (StrUtil.isBlank(username)) {
return null;
}
return this.lambdaQuery()
.eq(UserEntity::getUsername, username)
.one();
}2
3
4
5
6
7
8
9
如果条件理论上唯一,但数据库中可能存在历史脏数据,可以使用 getOne(wrapper, false),避免多条数据时直接抛异常。
public UserEntity getOneByPhone(String phone) {
if (StrUtil.isBlank(phone)) {
return null;
}
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getPhone, phone)
.last("LIMIT 1");
return this.getOne(wrapper, false);
}2
3
4
5
6
7
8
9
10
11
根据条件查询单条注意事项:
| 场景 | 建议 |
|---|---|
| 唯一字段 | 数据库必须有唯一索引 |
| 非唯一字段 | 不建议用 one() |
| 多条数据风险 | 可使用 getOne(wrapper, false) |
| 只判断存在 | 不要查整条数据,使用 count 或 exists |
| 空条件 | 不执行查询 |
| 排序 | 非唯一条件取第一条时必须明确排序 |
如果业务要求“必须唯一”,不要只靠代码约定,应建立数据库唯一约束。
查询列表
查询列表适合返回数据量可控的集合,例如启用角色列表、字典项列表、下拉选项等。对用户、订单、日志等可能增长较快的数据,应优先使用分页查询。
public List<UserPageVO> listEnabledUsers(String keyword) {
List<UserEntity> records = this.lambdaQuery()
.and(StrUtil.isNotBlank(keyword), item -> item
.like(UserEntity::getUsername, keyword)
.or()
.like(UserEntity::getNickname, keyword))
.eq(UserEntity::getStatus, 1)
.orderByAsc(UserEntity::getSortOrder)
.orderByDesc(UserEntity::getCreateTime)
.list();
if (CollUtil.isEmpty(records)) {
return List.of();
}
return userConvert.toPageVOList(records);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
查询列表注意事项:
| 检查项 | 建议 |
|---|---|
| 数据量 | 不确定数据量时使用分页 |
| 排序 | 必须明确排序 |
| 字段范围 | 不返回大字段 |
| 敏感字段 | 返回 VO,避免直接暴露 Entity |
| 缓存 | 字典、菜单等低频变化列表可缓存 |
| 条件 | 大表不允许无条件列表查询 |
列表接口不要为了前端省事而开放全表查询。数据量不可控时,必须分页。
查询总数
查询总数用于统计数量、校验是否存在、页面展示总量等场景。MyBatis-Plus 可以使用 count() 或 count(wrapper)。
public long countEnabledUsers() {
return this.lambdaQuery()
.eq(UserEntity::getStatus, 1)
.count();
}2
3
4
5
带条件统计:
public long countByPhone(String phone) {
if (StrUtil.isBlank(phone)) {
return 0L;
}
return this.lambdaQuery()
.eq(UserEntity::getPhone, phone)
.count();
}2
3
4
5
6
7
8
9
查询总数注意事项:
| 场景 | 建议 |
|---|---|
| 存在性判断 | 可以使用 count > 0 |
| 大表统计 | 注意 COUNT 性能 |
| 高频统计 | 考虑缓存或汇总表 |
| 多条件统计 | 确保索引命中 |
| 报表统计 | 推荐 XML 或汇总表 |
| 实时总数 | 大表中要评估成本 |
大表中频繁 COUNT(*) 会产生明显压力。列表页如果不强依赖总数,可以考虑关闭 COUNT 或使用游标分页。
判断是否存在
判断是否存在常用于唯一性校验、关联关系校验、删除前引用校验等。MyBatis-Plus Service 层提供 count 能力,部分版本也提供 exists 能力。为了兼容性和可读性,业务项目中常用 count > 0。
public boolean existsByUsername(String username) {
if (StrUtil.isBlank(username)) {
return false;
}
long count = this.lambdaQuery()
.eq(UserEntity::getUsername, username)
.count();
return count > 0;
}2
3
4
5
6
7
8
9
10
11
排除当前 ID 的存在性判断:
public boolean existsByPhone(String phone, Long excludeId) {
if (StrUtil.isBlank(phone)) {
return false;
}
long count = this.lambdaQuery()
.eq(UserEntity::getPhone, phone)
.ne(excludeId != null, UserEntity::getId, excludeId)
.count();
return count > 0;
}2
3
4
5
6
7
8
9
10
11
12
存在性判断注意事项:
| 场景 | 建议 |
|---|---|
| 唯一校验 | count > 0 加数据库唯一索引兜底 |
| 删除引用校验 | 查询关联表是否存在 |
| 权限校验 | 带上用户、租户、数据范围条件 |
| 大表 | 只查必要条件,确保索引命中 |
| 错误提示 | Service 层转换为明确业务异常 |
存在性判断不要查询完整实体再判断是否为空,除非后续确实要使用实体字段。
保存或更新
保存或更新适合“有 ID 就更新,没有 ID 就新增”的简单场景。MyBatis-Plus 提供 saveOrUpdate(entity) 和 saveOrUpdateBatch(entityList)。
@Transactional(rollbackFor = Exception.class)
public Long saveOrUpdateUser(UserEntity entity) {
boolean success = this.saveOrUpdate(entity);
if (!success) {
throw new BusinessException("保存用户失败");
}
log.info("保存或更新用户成功,用户ID:{}", entity.getId());
return entity.getId();
}2
3
4
5
6
7
8
9
10
但是业务系统中不建议滥用 saveOrUpdate。新增和修改的校验规则通常不同,例如新增要校验用户名,修改要校验数据是否存在、状态是否允许修改、唯一字段要排除当前 ID。
推荐业务写法:
@Transactional(rollbackFor = Exception.class)
public Long saveOrUpdateUserByDto(UserUpdateDTO dto) {
if (dto.getId() == null) {
throw new BusinessException("新增用户请调用新增接口");
}
this.updateUser(dto);
return dto.getId();
}2
3
4
5
6
7
8
9
保存或更新注意事项:
| 场景 | 是否推荐 |
|---|---|
| 简单配置表 | 可以使用 |
| 后台管理核心表 | 谨慎使用 |
| 新增和修改规则一致 | 可以使用 |
| 新增和修改规则不同 | 不推荐 |
| 外部同步数据 | 可根据业务唯一键自定义保存或更新 |
| 批量导入 | 谨慎,需要明确匹配规则 |
对于普通业务接口,新增和修改建议拆成两个接口,避免语义混乱。
批量保存或更新
批量保存或更新适合同步外部数据、导入配置项、批量维护字典等场景。MyBatis-Plus 提供 saveOrUpdateBatch。
@Transactional(rollbackFor = Exception.class)
public void batchSaveOrUpdateUsers(List<UserEntity> users) {
if (CollUtil.isEmpty(users)) {
return;
}
boolean success = this.saveOrUpdateBatch(users, 1000);
if (!success) {
throw new BusinessException("批量保存或更新用户失败");
}
log.info("批量保存或更新用户成功,数量:{}", users.size());
}2
3
4
5
6
7
8
9
10
11
12
13
批量保存或更新注意事项:
| 检查项 | 建议 |
|---|---|
| 匹配规则 | 默认按主键判断,不一定符合业务唯一键 |
| 批次大小 | 建议指定 batchSize |
| 数据校验 | 保存前先校验文件内重复和数据库重复 |
| 事务范围 | 大批量数据不要一个事务覆盖全部 |
| 性能 | 批量更新比批量新增更重,要压测 |
| 并发 | 外部同步数据要考虑幂等和并发 |
如果需要按业务唯一键更新,例如 username 或 orderNo,不要直接依赖 saveOrUpdateBatch,应先查出现有数据,再自行区分新增和修改。
字段局部更新
字段局部更新适合修改状态、手机号、备注、排序、密码等单个或少量字段。推荐使用 LambdaUpdateWrapper,避免构造完整 Entity。
@Transactional(rollbackFor = Exception.class)
public void updateUserStatus(Long userId, Integer status) {
if (userId == null) {
throw new BusinessException("用户ID不能为空");
}
if (status == null) {
throw new BusinessException("用户状态不能为空");
}
boolean updated = this.lambdaUpdate()
.eq(UserEntity::getId, userId)
.set(UserEntity::getStatus, status)
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("用户状态修改失败");
}
log.info("局部更新用户状态成功,用户ID:{},状态:{}", userId, status);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
修改备注,允许清空字段:
@Transactional(rollbackFor = Exception.class)
public void updateUserRemark(Long userId, String remark) {
if (userId == null) {
throw new BusinessException("用户ID不能为空");
}
boolean updated = this.lambdaUpdate()
.eq(UserEntity::getId, userId)
.set(UserEntity::getRemark, StrUtil.nullToEmpty(remark))
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("用户备注修改失败");
}
log.info("局部更新用户备注成功,用户ID:{}", userId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
字段局部更新注意事项:
| 场景 | 建议 |
|---|---|
| 修改状态 | 使用 LambdaUpdateWrapper |
| 修改备注 | 明确是否允许清空 |
| 修改密码 | 单独接口,记录操作日志,不返回密码 |
| 修改余额 | 不建议普通局部更新,必须有业务流水 |
| 修改排序 | 可批量更新 |
| 修改手机号 | 先做唯一性校验 |
| 修改权限字段 | 必须做权限校验 |
局部更新中,不建议前端传字段名和字段值让后端动态更新任意字段。确实需要动态字段更新时,必须做字段白名单。
空字段更新策略
空字段更新策略决定 DTO 中的 null 值是否更新到数据库。MyBatis-Plus 支持通过全局配置、@TableField(updateStrategy = ...) 和 Wrapper set 控制空字段更新行为。
常见策略如下:
| 策略 | 说明 |
|---|---|
NOT_NULL | 字段非 null 才更新 |
NOT_EMPTY | 字符串非空才更新 |
ALWAYS | 始终更新,包括 null |
NEVER | 从不更新 |
DEFAULT | 使用全局默认策略 |
实体字段中配置空字段更新:
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String remark;2
该配置表示更新时即使 remark 为 null,也会更新到数据库。适合明确允许清空的字段,但要谨慎使用。
使用 Wrapper 强制清空字段:
@Transactional(rollbackFor = Exception.class)
public void clearUserEmail(Long userId) {
if (userId == null) {
throw new BusinessException("用户ID不能为空");
}
boolean updated = this.lambdaUpdate()
.eq(UserEntity::getId, userId)
.set(UserEntity::getEmail, null)
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("清空用户邮箱失败");
}
log.info("清空用户邮箱成功,用户ID:{}", userId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
使用 DTO 局部更新时,推荐区分“字段未传”和“字段传 null”。普通 JSON DTO 无法天然区分这两种语义,因此建议采用以下方案之一:
| 方案 | 说明 |
|---|---|
| 明确接口语义 | 修改接口默认忽略 null,清空字段提供单独接口 |
| Wrapper 更新 | 对需要清空的字段使用专门方法 |
| Patch DTO | 使用更明确的字段变更结构 |
| Map 入参 | 可表达字段是否存在,但类型安全较差 |
| JSON Patch | 适合复杂开放 API,内部系统较少使用 |
推荐做法是:普通修改接口中 null 表示不修改;需要清空字段时,提供专门接口或专门 DTO 字段。例如“清空邮箱”“清空备注”“解绑手机号”等明确动作,不要依赖前端传 null 的隐式语义。
CRUD 示例服务汇总
下面给出一个可放入项目中的 CRUD 示例服务,集中展示新增、修改、删除、查询、统计、存在性判断、保存或更新和局部更新的常见写法。
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserCrudExampleService.java
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.common.result.ResultCode;
import io.github.atengk.module.system.user.convert.UserConvert;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import io.github.atengk.module.system.user.vo.UserDetailVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 用户 CRUD 示例服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserCrudExampleService extends ServiceImpl<UserMapper, UserEntity> {
private final UserConvert userConvert;
/**
* 单条新增用户
*
* @param dto 用户新增参数
* @return 用户ID
*/
@Transactional(rollbackFor = Exception.class)
public Long add(UserAddDTO dto) {
this.checkUsernameUnique(dto.getUsername(), null);
UserEntity entity = userConvert.toEntity(dto);
boolean saved = this.save(entity);
if (!saved) {
throw new BusinessException("新增用户失败");
}
log.info("新增用户成功,用户ID:{}", entity.getId());
return entity.getId();
}
/**
* 批量新增用户
*
* @param dtoList 用户新增参数列表
* @return 用户ID列表
*/
@Transactional(rollbackFor = Exception.class)
public List<Long> batchAdd(List<UserAddDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return List.of();
}
List<UserEntity> entities = dtoList.stream()
.peek(dto -> this.checkUsernameUnique(dto.getUsername(), null))
.map(userConvert::toEntity)
.toList();
this.saveBatch(entities, 1000);
log.info("批量新增用户成功,数量:{}", entities.size());
return entities.stream().map(UserEntity::getId).toList();
}
/**
* 根据ID修改用户
*
* @param dto 用户修改参数
*/
@Transactional(rollbackFor = Exception.class)
public void updateByUserId(UserUpdateDTO dto) {
UserEntity entity = this.getRequiredById(dto.getId());
userConvert.updateEntity(dto, entity);
boolean updated = this.updateById(entity);
if (!updated) {
throw new BusinessException("修改用户失败");
}
log.info("根据ID修改用户成功,用户ID:{}", dto.getId());
}
/**
* 根据条件禁用用户
*
* @param ids 用户ID列表
*/
@Transactional(rollbackFor = Exception.class)
public void disableByIds(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return;
}
boolean updated = this.lambdaUpdate()
.in(UserEntity::getId, ids)
.set(UserEntity::getStatus, 0)
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("禁用用户失败");
}
log.info("根据条件禁用用户成功,数量:{}", ids.size());
}
/**
* 根据ID删除用户
*
* @param id 用户ID
*/
@Transactional(rollbackFor = Exception.class)
public void deleteByUserId(Long id) {
UserEntity entity = this.getRequiredById(id);
boolean removed = this.removeById(entity.getId());
if (!removed) {
throw new BusinessException("删除用户失败");
}
log.info("根据ID删除用户成功,用户ID:{}", id);
}
/**
* 根据ID查询详情
*
* @param id 用户ID
* @return 用户详情
*/
public UserDetailVO detail(Long id) {
UserEntity entity = this.getRequiredById(id);
return userConvert.toDetailVO(entity);
}
/**
* 根据用户名查询单条用户
*
* @param username 用户名
* @return 用户实体
*/
public UserEntity getByUsername(String username) {
if (StrUtil.isBlank(username)) {
return null;
}
return this.lambdaQuery()
.eq(UserEntity::getUsername, username)
.one();
}
/**
* 查询启用用户列表
*
* @return 用户列表
*/
public List<UserEntity> listEnabled() {
return this.lambdaQuery()
.eq(UserEntity::getStatus, 1)
.orderByDesc(UserEntity::getCreateTime)
.list();
}
/**
* 查询启用用户数量
*
* @return 启用用户数量
*/
public long countEnabled() {
return this.lambdaQuery()
.eq(UserEntity::getStatus, 1)
.count();
}
/**
* 判断用户名是否存在
*
* @param username 用户名
* @return 是否存在
*/
public boolean existsUsername(String username) {
if (StrUtil.isBlank(username)) {
return false;
}
long count = this.lambdaQuery()
.eq(UserEntity::getUsername, username)
.count();
return count > 0;
}
/**
* 保存或更新用户
*
* @param entity 用户实体
*/
@Transactional(rollbackFor = Exception.class)
public void saveOrUpdateUser(UserEntity entity) {
boolean success = this.saveOrUpdate(entity);
if (!success) {
throw new BusinessException("保存或更新用户失败");
}
log.info("保存或更新用户成功,用户ID:{}", entity.getId());
}
/**
* 批量保存或更新用户
*
* @param entities 用户实体列表
*/
@Transactional(rollbackFor = Exception.class)
public void batchSaveOrUpdateUser(List<UserEntity> entities) {
if (CollUtil.isEmpty(entities)) {
return;
}
boolean success = this.saveOrUpdateBatch(entities, 1000);
if (!success) {
throw new BusinessException("批量保存或更新用户失败");
}
log.info("批量保存或更新用户成功,数量:{}", entities.size());
}
/**
* 局部更新用户备注
*
* @param id 用户ID
* @param remark 备注
*/
@Transactional(rollbackFor = Exception.class)
public void updateRemark(Long id, String remark) {
if (id == null) {
throw new BusinessException("用户ID不能为空");
}
boolean updated = this.lambdaUpdate()
.eq(UserEntity::getId, id)
.set(UserEntity::getRemark, StrUtil.nullToEmpty(remark))
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("修改用户备注失败");
}
log.info("局部更新用户备注成功,用户ID:{}", id);
}
/**
* 根据ID查询用户,不存在时抛出异常
*
* @param id 用户ID
* @return 用户实体
*/
private UserEntity getRequiredById(Long id) {
if (id == null) {
throw new BusinessException("用户ID不能为空");
}
UserEntity entity = this.getById(id);
if (entity == null) {
throw new BusinessException(ResultCode.DATA_NOT_FOUND, "用户不存在");
}
return entity;
}
/**
* 校验用户名唯一
*
* @param username 用户名
* @param excludeId 排除的用户ID
*/
private void checkUsernameUnique(String username, Long excludeId) {
if (StrUtil.isBlank(username)) {
throw new BusinessException("用户名不能为空");
}
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getUsername, username)
.ne(excludeId != null, UserEntity::getId, excludeId);
long count = this.count(wrapper);
if (count > 0) {
throw new BusinessException(ResultCode.DATA_DUPLICATE, "用户名已存在");
}
}
/**
* 校验手机号唯一
*
* @param phone 手机号
* @param excludeId 排除的用户ID
*/
private void checkPhoneUnique(String phone, Long excludeId) {
if (StrUtil.isBlank(phone)) {
return;
}
long count = this.lambdaQuery()
.eq(UserEntity::getPhone, phone)
.ne(excludeId != null, UserEntity::getId, excludeId)
.count();
if (count > 0) {
throw new BusinessException(ResultCode.DATA_DUPLICATE, "手机号已存在");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
CRUD 常用场景的总体原则是:简单单表操作用 MyBatis-Plus 通用方法,动态条件用 Lambda Wrapper,复杂查询用 XML;写操作放 Service 层加业务校验和事务,读操作返回 VO,敏感字段不直接暴露 Entity。
自定义 SQL 场景
自定义 SQL 用于处理 MyBatis-Plus 通用 CRUD 和 Wrapper 不适合表达的场景,例如复杂多表 JOIN、动态 SQL、批量参数、聚合统计、分组统计、排名查询、窗口函数、复杂报表和 SQL 片段复用。项目中不应为了“全都使用 Wrapper”而牺牲 SQL 可读性。只要 SQL 已经明显具备报表、关联、统计、嵌套、窗口函数等特征,就应优先使用 XML。
XML SQL 编写
XML SQL 是 MyBatis 中最适合维护复杂 SQL 的方式。它支持动态标签、SQL 片段复用、ResultMap、TypeHandler、foreach 批量参数和复杂结果映射。对于企业后台系统,推荐将复杂查询、复杂更新、报表统计、批量处理统一放到 XML 中维护。
Mapper 接口示例:
文件位置:src/main/java/io/github/atengk/module/system/user/mapper/UserMapper.java
package io.github.atengk.module.system.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.query.UserReportQuery;
import io.github.atengk.module.system.user.vo.UserReportVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 用户 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserMapper extends BaseMapper<UserEntity> {
/**
* 查询用户报表列表
*
* @param query 用户报表查询参数
* @return 用户报表列表
*/
List<UserReportVO> selectUserReportList(@Param("query") UserReportQuery query);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
XML 文件示例:
文件位置:src/main/resources/mapper/system/UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- namespace 必须与 Mapper 接口全限定名一致 -->
<mapper namespace="io.github.atengk.module.system.user.mapper.UserMapper">
<!-- 用户报表查询 -->
<select id="selectUserReportList" resultType="io.github.atengk.module.system.user.vo.UserReportVO">
SELECT
u.id,
u.username,
u.nickname,
u.phone,
u.status,
d.dept_name,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.pay_amount), 0) AS total_pay_amount
FROM sys_user u
LEFT JOIN sys_dept d ON d.id = u.dept_id AND d.deleted = 0
LEFT JOIN biz_order o ON o.user_id = u.id AND o.deleted = 0
<where>
u.deleted = 0
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.status != null">
AND u.status = #{query.status}
</if>
<if test="query.startTime != null">
AND u.create_time >= #{query.startTime}
</if>
<if test="query.endTime != null">
AND u.create_time <= #{query.endTime}
</if>
</where>
GROUP BY
u.id,
u.username,
u.nickname,
u.phone,
u.status,
d.dept_name
ORDER BY total_pay_amount DESC, u.create_time DESC
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
XML SQL 编写建议:
| 规范项 | 建议 |
|---|---|
| 文件位置 | src/main/resources/mapper/**/**Mapper.xml |
namespace | 必须与 Mapper 接口全限定名一致 |
id | 必须与 Mapper 方法名一致 |
| 参数 | 多参数必须使用 @Param |
| 查询字段 | 禁止无脑 SELECT * |
| 动态条件 | 使用 <where>、<if>、<foreach>、<trim> |
| 复杂返回 | 使用 VO 或 ResultMap |
| SQL 安全 | 值使用 #{},结构字段必须白名单 |
注解 SQL 编写
注解 SQL 适合短小、固定、低复杂度的 SQL。常见场景包括根据编码查 ID、查询某个单字段、修改简单状态、判断某个关系是否存在等。只要 SQL 较长、需要动态标签、多表 JOIN 或复杂结果映射,就应使用 XML。
注解 SQL 示例:
package io.github.atengk.module.system.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.atengk.module.system.user.entity.UserEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/**
* 用户 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserMapper extends BaseMapper<UserEntity> {
/**
* 根据用户名查询用户 ID
*
* @param username 用户名
* @return 用户 ID
*/
@Select("""
SELECT id
FROM sys_user
WHERE username = #{username}
AND deleted = 0
LIMIT 1
""")
Long selectIdByUsername(@Param("username") String username);
/**
* 修改用户状态
*
* @param id 用户 ID
* @param status 状态
* @return 影响行数
*/
@Update("""
UPDATE sys_user
SET status = #{status},
update_time = NOW()
WHERE id = #{id}
AND deleted = 0
""")
int updateStatusById(@Param("id") Long id, @Param("status") Integer status);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
注解 SQL 使用建议:
| 场景 | 建议 |
|---|---|
| 短 SQL | 可以使用注解 |
| 单字段查询 | 可以使用注解 |
| 简单状态修改 | 可以使用注解 |
| 多表 JOIN | 推荐 XML |
| 动态 SQL | 推荐 XML |
| 批量 SQL | 推荐 XML |
| 复杂 ResultMap | 必须使用 XML |
注解 SQL 不应滥用。注解里维护几十行 SQL,会降低可读性、可格式化性和后续维护效率。
动态 WHERE 条件
动态 WHERE 条件用于根据请求参数拼接查询条件。MyBatis XML 中推荐使用 <where> 标签,它会自动处理多余的 AND 或 OR。
查询参数对象示例:
文件位置:src/main/java/io/github/atengk/module/system/user/query/UserReportQuery.java
package io.github.atengk.module.system.user.query;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户报表查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserReportQuery {
/**
* 用户名
*/
private String username;
/**
* 部门 ID
*/
private Long deptId;
/**
* 状态
*/
private Integer status;
/**
* 创建开始时间
*/
private LocalDateTime startTime;
/**
* 创建结束时间
*/
private LocalDateTime endTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
动态 WHERE 示例:
<select id="selectUserListByCondition" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
id,
username,
nickname,
phone,
status,
create_time
FROM sys_user
<where>
deleted = 0
<if test="query.username != null and query.username != ''">
AND username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.deptId != null">
AND dept_id = #{query.deptId}
</if>
<if test="query.status != null">
AND status = #{query.status}
</if>
<if test="query.startTime != null">
AND create_time >= #{query.startTime}
</if>
<if test="query.endTime != null">
AND create_time <= #{query.endTime}
</if>
</where>
ORDER BY create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
动态 WHERE 编写建议:
| 条件类型 | 推荐写法 |
|---|---|
| 字符串 | != null and != '' |
| 数字 | != null |
| 时间 | != null |
| 集合 | != null and size > 0 |
| 固定条件 | 直接写入 where 内 |
| 逻辑删除 | 建议显式写 deleted = 0 |
| 租户条件 | 多租户插件或手动补充 |
动态条件中不要使用 ${} 拼接用户输入。普通值一律使用 #{}。
动态 SET 条件
动态 SET 条件用于按传入字段局部更新。MyBatis XML 中推荐使用 <set> 标签,它会自动处理多余的逗号。
动态更新示例:
<update id="updateUserSelective">
UPDATE sys_user
<set>
<if test="entity.nickname != null">
nickname = #{entity.nickname},
</if>
<if test="entity.phone != null">
phone = #{entity.phone},
</if>
<if test="entity.email != null">
email = #{entity.email},
</if>
<if test="entity.status != null">
status = #{entity.status},
</if>
update_time = NOW()
</set>
WHERE id = #{entity.id}
AND deleted = 0
</update>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Mapper 方法:
/**
* 动态更新用户字段
*
* @param entity 用户实体
* @return 影响行数
*/
int updateUserSelective(@Param("entity") UserEntity entity);2
3
4
5
6
7
动态 SET 注意事项:
| 风险点 | 建议 |
|---|---|
| 主键为空 | Service 层必须校验 |
| 全字段为空 | 至少更新 update_time,或提前阻止 |
| 空值清空 | 需要单独设计,普通动态 SET 会忽略 null |
| WHERE 条件 | 必须明确,禁止无条件更新 |
| 乐观锁 | 并发敏感表需要带 version 条件 |
| 审计字段 | 更新时间、更新人需要同步处理 |
如果只是简单局部更新,优先使用 LambdaUpdateWrapper;如果更新逻辑复杂或字段较多,再使用 XML 动态 SET。
foreach 批量参数
foreach 用于处理集合参数,常见于 IN 查询、批量插入、批量删除、批量更新等场景。
IN 查询示例:
<select id="selectUsersByIds" resultType="io.github.atengk.module.system.user.entity.UserEntity">
SELECT
id,
username,
nickname,
phone,
status,
create_time
FROM sys_user
WHERE deleted = 0
AND id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
批量插入示例:
<insert id="insertUserBatch">
INSERT INTO sys_user (
id,
username,
nickname,
phone,
status,
create_time,
update_time,
deleted,
version
)
VALUES
<foreach collection="users" item="item" separator=",">
(
#{item.id},
#{item.username},
#{item.nickname},
#{item.phone},
#{item.status},
#{item.createTime},
#{item.updateTime},
#{item.deleted},
#{item.version}
)
</foreach>
</insert>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
批量更新不同状态示例:
<update id="updateUserStatusBatch">
UPDATE sys_user
SET
status = CASE id
<foreach collection="users" item="item">
WHEN #{item.id} THEN #{item.status}
</foreach>
END,
update_time = NOW()
WHERE deleted = 0
AND id IN
<foreach collection="users" item="item" open="(" separator="," close=")">
#{item.id}
</foreach>
</update>2
3
4
5
6
7
8
9
10
11
12
13
14
15
foreach 使用建议:
| 场景 | 建议 |
|---|---|
| IN 查询 | 集合必须非空 |
| 批量插入 | 控制批次大小 |
| 批量更新 | 数据量大时谨慎使用 |
| 批量删除 | 优先逻辑删除 |
| 大集合 | 分批执行 |
| 参数校验 | Service 层先校验集合大小 |
不要把几万条数据一次性放入 foreach。大批量数据应分批处理,或使用数据库批量导入能力。
多表 JOIN 查询
多表 JOIN 查询适合用户列表带部门名称、订单列表带客户名称、商品列表带分类名称等场景。简单一对一关联可以直接 JOIN 返回 VO;一对多关系要谨慎,避免分页时主表记录重复。
VO 示例:
文件位置:src/main/java/io/github/atengk/module/system/user/vo/UserJoinVO.java
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户关联查询返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserJoinVO {
/**
* 用户 ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 部门名称
*/
private String deptName;
/**
* 状态
*/
private Integer status;
/**
* 创建时间
*/
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
JOIN 查询 SQL:
<select id="selectUserJoinList" resultType="io.github.atengk.module.system.user.vo.UserJoinVO">
SELECT
u.id,
u.username,
u.nickname,
u.status,
u.create_time,
d.dept_name
FROM sys_user u
LEFT JOIN sys_dept d ON d.id = u.dept_id AND d.deleted = 0
<where>
u.deleted = 0
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.deptId != null">
AND u.dept_id = #{query.deptId}
</if>
</where>
ORDER BY u.create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
JOIN 查询建议:
| 场景 | 建议 |
|---|---|
| 一对一 | 可以直接 JOIN |
| 一对多 | 谨慎 JOIN,可能导致重复行 |
| 分页 JOIN | 注意 COUNT SQL |
| 关联表逻辑删除 | JOIN 条件中补充 deleted = 0 |
| 关联字段 | 必须加索引 |
| 返回对象 | 使用 VO,不污染 Entity |
一对多分页场景建议先分页主表 ID,再查询子表集合并组装,避免分页结果不准确。
子查询
子查询适合表达“存在某类关联数据”“统计后过滤”“取最大值对应记录”等场景。简单子查询可以写在 XML 中;复杂子查询建议评估是否改为 JOIN 或临时汇总表。
查询拥有角色的用户:
<select id="selectUsersHasRole" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
id,
username,
nickname,
phone,
status,
create_time
FROM sys_user u
WHERE u.deleted = 0
AND EXISTS (
SELECT 1
FROM sys_user_role ur
WHERE ur.user_id = u.id
AND ur.deleted = 0
)
ORDER BY u.create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
查询订单金额超过平均金额的用户:
<select id="selectUsersOverAvgOrderAmount" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
u.id,
u.username,
u.nickname,
u.phone,
u.status,
u.create_time
FROM sys_user u
WHERE u.deleted = 0
AND u.id IN (
SELECT o.user_id
FROM biz_order o
WHERE o.deleted = 0
GROUP BY o.user_id
HAVING SUM(o.pay_amount) > (
SELECT AVG(t.total_amount)
FROM (
SELECT SUM(pay_amount) AS total_amount
FROM biz_order
WHERE deleted = 0
GROUP BY user_id
) t
)
)
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
子查询使用建议:
| 场景 | 建议 |
|---|---|
| 是否存在 | 使用 EXISTS |
| 排除存在 | 使用 NOT EXISTS |
| 小结果集 | 可以使用 IN |
| 大结果集 | 优先评估 EXISTS 或 JOIN |
| 复杂统计 | 考虑汇总表 |
| 性能问题 | 必须查看执行计划 |
子查询可读性较好,但复杂嵌套会影响维护。超过两层嵌套时,应考虑拆分 SQL 或引入汇总表。
UNION 查询
UNION 用于合并多个查询结果,并默认去重;UNION ALL 用于合并结果但不去重。多数业务场景中,如果不需要去重,应优先使用 UNION ALL,性能通常更好。
查询用户和部门的搜索建议:
<select id="selectSearchSuggestions" resultType="io.github.atengk.common.vo.OptionVO">
SELECT
CAST(id AS CHAR) AS value,
username AS label,
'USER' AS type
FROM sys_user
WHERE deleted = 0
AND username LIKE CONCAT('%', #{keyword}, '%')
UNION ALL
SELECT
CAST(id AS CHAR) AS value,
dept_name AS label,
'DEPT' AS type
FROM sys_dept
WHERE deleted = 0
AND dept_name LIKE CONCAT('%', #{keyword}, '%')
ORDER BY type ASC, label ASC
LIMIT 20
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
返回对象示例:
文件位置:src/main/java/io/github/atengk/common/vo/OptionVO.java
package io.github.atengk.common.vo;
import lombok.Getter;
import lombok.Setter;
/**
* 通用选项返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class OptionVO {
/**
* 选项值
*/
private String value;
/**
* 选项标签
*/
private String label;
/**
* 选项类型
*/
private String type;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
UNION 使用建议:
| 场景 | 建议 |
|---|---|
| 多来源搜索 | 可以使用 |
| 需要去重 | 使用 UNION |
| 不需要去重 | 使用 UNION ALL |
| 字段数量 | 每个 SELECT 字段数量必须一致 |
| 字段类型 | 对应字段类型应兼容 |
| 排序 | 最终统一 ORDER BY |
| 分页 | 复杂分页建议外层包裹 |
UNION 查询不要过度复杂。多个来源逻辑差异大时,可以拆成多个查询后在 Service 层合并。
聚合统计
聚合统计用于 COUNT、SUM、AVG、MAX、MIN 等统计场景。固定统计接口建议返回强类型 VO,而不是 Map<String, Object>。
统计 VO 示例:
文件位置:src/main/java/io/github/atengk/module/system/user/vo/UserStatisticsVO.java
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 用户统计返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserStatisticsVO {
/**
* 用户总数
*/
private Long totalCount;
/**
* 启用用户数
*/
private Long enabledCount;
/**
* 禁用用户数
*/
private Long disabledCount;
/**
* 余额合计
*/
private BigDecimal totalBalanceAmount;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
聚合统计 SQL:
<select id="selectUserStatistics" resultType="io.github.atengk.module.system.user.vo.UserStatisticsVO">
SELECT
COUNT(1) AS total_count,
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS enabled_count,
SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) AS disabled_count,
COALESCE(SUM(balance_amount), 0) AS total_balance_amount
FROM sys_user
WHERE deleted = 0
<if test="tenantId != null">
AND tenant_id = #{tenantId}
</if>
</select>2
3
4
5
6
7
8
9
10
11
12
聚合统计建议:
| 场景 | 建议 |
|---|---|
| 固定统计 | 返回 VO |
| 金额统计 | 使用 BigDecimal |
| 空值处理 | 使用 COALESCE |
| 大表统计 | 考虑汇总表或缓存 |
| 高频看板 | 不建议每次实时扫主表 |
| 多维统计 | 明确索引和分组字段 |
分组统计
分组统计用于按日期、状态、部门、租户、类型等维度统计数据。分组统计的返回字段应明确,字段别名要与 VO 属性一致。
按状态统计:
<select id="selectUserCountByStatus" resultType="io.github.atengk.module.system.user.vo.UserStatusCountVO">
SELECT
status,
COUNT(1) AS user_count
FROM sys_user
WHERE deleted = 0
GROUP BY status
ORDER BY status ASC
</select>2
3
4
5
6
7
8
9
按日期统计:
<select id="selectDailyUserCount" resultType="io.github.atengk.module.system.user.vo.DailyUserCountVO">
SELECT
DATE(create_time) AS stat_date,
COUNT(1) AS user_count
FROM sys_user
WHERE deleted = 0
AND create_time >= #{startTime}
AND create_time < #{endTime}
GROUP BY DATE(create_time)
ORDER BY stat_date ASC
</select>2
3
4
5
6
7
8
9
10
11
VO 示例:
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
/**
* 每日用户数量统计返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class DailyUserCountVO {
/**
* 统计日期
*/
private LocalDate statDate;
/**
* 用户数量
*/
private Long userCount;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
分组统计建议:
| 场景 | 建议 |
|---|---|
| 按日期统计 | 注意时间范围和索引 |
| 按状态统计 | 状态字段低区分度,通常结合其他条件 |
| 按部门统计 | 部门字段需要索引 |
| 按租户统计 | 多租户报表常用 |
| HAVING | 只在聚合后过滤时使用 |
| 大表 | 优先汇总表 |
分组统计中的 GROUP BY 字段和查询时间范围必须结合索引设计,否则大表性能会很差。
排名查询
排名查询常用于排行榜、销售额排名、订单数排名、活跃用户排名等场景。MySQL 8 支持窗口函数,简单排名可以使用 ORDER BY + LIMIT,复杂排名建议使用 ROW_NUMBER()、RANK()、DENSE_RANK()。
简单排行榜:
<select id="selectTopUsersByAmount" resultType="io.github.atengk.module.system.user.vo.UserRankVO">
SELECT
u.id AS user_id,
u.username,
u.nickname,
COALESCE(SUM(o.pay_amount), 0) AS total_amount
FROM sys_user u
INNER JOIN biz_order o ON o.user_id = u.id AND o.deleted = 0
WHERE u.deleted = 0
GROUP BY u.id, u.username, u.nickname
ORDER BY total_amount DESC
LIMIT #{limit}
</select>2
3
4
5
6
7
8
9
10
11
12
13
排名 VO:
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 用户排名返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserRankVO {
/**
* 用户 ID
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 合计金额
*/
private BigDecimal totalAmount;
/**
* 排名
*/
private Integer rankNo;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
普通排行榜如果不需要并列排名,可以在 Service 层根据列表顺序补充排名;如果需要数据库层精确排名,使用窗口函数更合适。
窗口函数查询
窗口函数适合排名、分组内排序、同比环比、累计值、移动平均等复杂统计场景。MySQL 8 支持常见窗口函数,例如 ROW_NUMBER()、RANK()、DENSE_RANK()、SUM() OVER() 等。
用户消费金额排名:
<select id="selectUserAmountRank" resultType="io.github.atengk.module.system.user.vo.UserRankVO">
SELECT
t.user_id,
t.username,
t.nickname,
t.total_amount,
t.rank_no
FROM (
SELECT
u.id AS user_id,
u.username,
u.nickname,
COALESCE(SUM(o.pay_amount), 0) AS total_amount,
DENSE_RANK() OVER (ORDER BY COALESCE(SUM(o.pay_amount), 0) DESC) AS rank_no
FROM sys_user u
LEFT JOIN biz_order o ON o.user_id = u.id AND o.deleted = 0
WHERE u.deleted = 0
GROUP BY u.id, u.username, u.nickname
) t
WHERE t.rank_no <= #{topN}
ORDER BY t.rank_no ASC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
按部门内用户消费排名:
<select id="selectUserRankInDept" resultType="io.github.atengk.module.system.user.vo.UserDeptRankVO">
SELECT
t.dept_id,
t.dept_name,
t.user_id,
t.username,
t.total_amount,
t.rank_no
FROM (
SELECT
d.id AS dept_id,
d.dept_name,
u.id AS user_id,
u.username,
COALESCE(SUM(o.pay_amount), 0) AS total_amount,
ROW_NUMBER() OVER (
PARTITION BY d.id
ORDER BY COALESCE(SUM(o.pay_amount), 0) DESC
) AS rank_no
FROM sys_dept d
INNER JOIN sys_user u ON u.dept_id = d.id AND u.deleted = 0
LEFT JOIN biz_order o ON o.user_id = u.id AND o.deleted = 0
WHERE d.deleted = 0
GROUP BY d.id, d.dept_name, u.id, u.username
) t
WHERE t.rank_no <= #{topN}
ORDER BY t.dept_id ASC, t.rank_no ASC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
窗口函数建议:
| 函数 | 说明 |
|---|---|
ROW_NUMBER() | 连续行号,不处理并列 |
RANK() | 有并列排名,后续排名跳号 |
DENSE_RANK() | 有并列排名,后续排名不跳号 |
SUM() OVER() | 窗口内累计或分区求和 |
AVG() OVER() | 窗口内平均值 |
LAG() | 取上一行数据 |
LEAD() | 取下一行数据 |
窗口函数适合报表类 SQL,但不适合写在 Wrapper 中。推荐统一放在 XML 中维护,并配合 Mapper 单元测试验证结果。
复杂报表查询
复杂报表查询通常包括多表关联、分组聚合、时间维度、条件筛选、排序、排名、权限过滤等。复杂报表不建议直接压业务主库高频实时查询。数据量较大时,应考虑汇总表、宽表、定时任务、异步报表、OLAP 引擎或缓存。
报表查询参数:
文件位置:src/main/java/io/github/atengk/module/report/query/UserOrderReportQuery.java
package io.github.atengk.module.report.query;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户订单报表查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserOrderReportQuery {
/**
* 部门 ID
*/
private Long deptId;
/**
* 用户名
*/
private String username;
/**
* 订单开始时间
*/
private LocalDateTime startTime;
/**
* 订单结束时间
*/
private LocalDateTime endTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
报表返回对象:
文件位置:src/main/java/io/github/atengk/module/report/vo/UserOrderReportVO.java
package io.github.atengk.module.report.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 用户订单报表返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserOrderReportVO {
/**
* 部门 ID
*/
private Long deptId;
/**
* 部门名称
*/
private String deptName;
/**
* 用户 ID
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 订单数量
*/
private Long orderCount;
/**
* 支付金额合计
*/
private BigDecimal payAmount;
/**
* 退款金额合计
*/
private BigDecimal refundAmount;
/**
* 实收金额
*/
private BigDecimal actualAmount;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
复杂报表 SQL:
<select id="selectUserOrderReport" resultType="io.github.atengk.module.report.vo.UserOrderReportVO">
SELECT
d.id AS dept_id,
d.dept_name,
u.id AS user_id,
u.username,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.pay_amount), 0) AS pay_amount,
COALESCE(SUM(o.refund_amount), 0) AS refund_amount,
COALESCE(SUM(o.pay_amount - o.refund_amount), 0) AS actual_amount
FROM sys_user u
LEFT JOIN sys_dept d ON d.id = u.dept_id AND d.deleted = 0
LEFT JOIN biz_order o ON o.user_id = u.id AND o.deleted = 0
<where>
u.deleted = 0
<if test="query.deptId != null">
AND u.dept_id = #{query.deptId}
</if>
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.startTime != null">
AND o.create_time >= #{query.startTime}
</if>
<if test="query.endTime != null">
AND o.create_time < #{query.endTime}
</if>
</where>
GROUP BY
d.id,
d.dept_name,
u.id,
u.username
ORDER BY actual_amount DESC, order_count DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
复杂报表建议:
| 场景 | 建议 |
|---|---|
| 小数据量临时报表 | XML 实时查询可以接受 |
| 高频看板 | 使用汇总表或缓存 |
| 大数据量统计 | 异步任务生成报表 |
| 跨库报表 | 不建议直接业务库 JOIN |
| 多维分析 | 考虑 OLAP 或数仓 |
| 导出报表 | 限制条件和导出数量 |
| 慢 SQL | 必须分析执行计划 |
复杂报表 SQL 不宜和普通业务查询混在同一个 Mapper 中。项目较大时,可以单独建立 report 模块或 ReportMapper。
SQL 片段复用
SQL 片段复用用于减少重复字段列表、重复 WHERE 条件和重复 JOIN 语句。MyBatis XML 中使用 <sql> 定义片段,使用 <include> 引用片段。
字段片段示例:
<sql id="UserBaseColumns">
u.id,
u.username,
u.nickname,
u.phone,
u.email,
u.status,
u.create_time,
u.update_time
</sql>2
3
4
5
6
7
8
9
10
JOIN 片段示例:
<sql id="UserDeptJoin">
LEFT JOIN sys_dept d ON d.id = u.dept_id AND d.deleted = 0
</sql>2
3
WHERE 片段示例:
<sql id="UserDynamicWhere">
<where>
u.deleted = 0
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.status != null">
AND u.status = #{query.status}
</if>
<if test="query.startTime != null">
AND u.create_time >= #{query.startTime}
</if>
<if test="query.endTime != null">
AND u.create_time <= #{query.endTime}
</if>
</where>
</sql>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
引用片段:
<select id="selectUserPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
<include refid="UserBaseColumns"/>,
d.dept_name
FROM sys_user u
<include refid="UserDeptJoin"/>
<include refid="UserDynamicWhere"/>
ORDER BY u.create_time DESC
</select>2
3
4
5
6
7
8
9
完整 XML 片段组织示例:
<mapper namespace="io.github.atengk.module.system.user.mapper.UserMapper">
<sql id="UserBaseColumns">
u.id,
u.username,
u.nickname,
u.phone,
u.email,
u.status,
u.create_time,
u.update_time
</sql>
<sql id="UserDeptJoin">
LEFT JOIN sys_dept d ON d.id = u.dept_id AND d.deleted = 0
</sql>
<sql id="UserDynamicWhere">
<where>
u.deleted = 0
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.status != null">
AND u.status = #{query.status}
</if>
</where>
</sql>
<select id="selectUserPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
<include refid="UserBaseColumns"/>,
d.dept_name
FROM sys_user u
<include refid="UserDeptJoin"/>
<include refid="UserDynamicWhere"/>
ORDER BY u.create_time DESC
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
SQL 片段复用建议:
| 片段类型 | 是否推荐 |
|---|---|
| 字段列表 | 推荐 |
| 固定 JOIN | 推荐 |
| 通用 WHERE | 可以使用 |
| 复杂动态 WHERE | 谨慎使用 |
| 大段业务 SQL | 不建议过度复用 |
| 跨 Mapper 复用 | 不建议,维护成本高 |
SQL 片段复用的目标是减少重复,不是把 SQL 拆得过碎。片段过多会降低可读性,尤其是复杂报表 SQL,应优先保证完整 SQL 的可理解性。
自动填充
自动填充是 MyBatis-Plus 提供的实体字段填充机制,通常用于审计字段和上下文字段,例如 createBy、updateBy、createTime、updateTime、deptId、tenantId。自动填充应作为通用基础能力放在框架层统一处理,不建议散落在每个 Service 方法中手动赋值。
常见自动填充字段如下:
| 字段 | 填充时机 | 来源 |
|---|---|---|
createBy | 新增时 | 当前登录用户 |
updateBy | 新增和修改时 | 当前登录用户 |
createTime | 新增时 | 当前服务器时间 |
updateTime | 新增和修改时 | 当前服务器时间 |
deptId | 新增时 | 当前登录用户所属部门 |
tenantId | 新增时 | 当前租户上下文 |
MetaObjectHandler 配置
MetaObjectHandler 是 MyBatis-Plus 自动填充的核心接口。项目中只需要实现该接口,并将实现类交给 Spring 容器管理即可。实体字段必须通过 @TableField(fill = FieldFill.INSERT) 或 @TableField(fill = FieldFill.INSERT_UPDATE) 声明填充策略,否则处理器不会自动填充对应字段。
推荐先定义一个公共实体父类。
文件位置:src/main/java/io/github/atengk/common/entity/BaseEntity.java
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 实体公共父类
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class BaseEntity implements Serializable {
/**
* 主键 ID
*/
@TableId
private Long id;
/**
* 创建人 ID
*/
@TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新人 ID
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 逻辑删除标识:0未删除,1已删除
*/
@TableLogic
private Integer deleted;
/**
* 乐观锁版本号
*/
@Version
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
如果系统所有业务表都需要部门字段和租户字段,可以定义租户实体父类;如果不是所有表都需要,不建议强行放入 BaseEntity。
文件位置:src/main/java/io/github/atengk/common/entity/TenantBaseEntity.java
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Getter;
import lombok.Setter;
/**
* 租户实体公共父类
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class TenantBaseEntity extends BaseEntity {
/**
* 租户 ID
*/
@TableField(fill = FieldFill.INSERT)
private Long tenantId;
/**
* 部门 ID
*/
@TableField(fill = FieldFill.INSERT)
private Long deptId;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
自动填充处理器如下。
文件位置:src/main/java/io/github/atengk/framework/mybatis/MyBatisPlusMetaObjectHandler.java
package io.github.atengk.framework.mybatis;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 字段自动填充处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MyBatisPlusMetaObjectHandler implements MetaObjectHandler {
private final CurrentUserContext currentUserContext;
private final CurrentTenantContext currentTenantContext;
/**
* 新增数据时自动填充字段
*
* @param metaObject 元对象
*/
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
Long userId = currentUserContext.getUserIdOrDefault();
Long deptId = currentUserContext.getDeptIdOrDefault();
Long tenantId = currentTenantContext.getTenantIdOrDefault();
this.strictInsertFill(metaObject, "createBy", Long.class, userId);
this.strictInsertFill(metaObject, "updateBy", Long.class, userId);
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
this.fillIfFieldExists(metaObject, "deptId", deptId);
this.fillIfFieldExists(metaObject, "tenantId", tenantId);
log.debug("新增字段自动填充完成,用户ID:{},部门ID:{},租户ID:{}",
userId, deptId, tenantId);
}
/**
* 修改数据时自动填充字段
*
* @param metaObject 元对象
*/
@Override
public void updateFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
Long userId = currentUserContext.getUserIdOrDefault();
this.strictUpdateFill(metaObject, "updateBy", Long.class, userId);
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, now);
log.debug("更新字段自动填充完成,用户ID:{}", userId);
}
/**
* 字段存在时执行插入填充
*
* @param metaObject 元对象
* @param fieldName 字段名称
* @param fieldValue 字段值
*/
private void fillIfFieldExists(MetaObject metaObject, String fieldName, Long fieldValue) {
if (!metaObject.hasSetter(fieldName)) {
return;
}
Object oldValue = getFieldValByName(fieldName, metaObject);
if (ObjectUtil.isNull(oldValue) && ObjectUtil.isNotNull(fieldValue)) {
setFieldValByName(fieldName, fieldValue, metaObject);
}
log.trace("字段自动填充检查完成,字段名:{},原值:{},新值:{}",
fieldName, StrUtil.toStringOrNull(oldValue), fieldValue);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
这里的 fillIfFieldExists 用于处理 deptId、tenantId 这类并非所有实体都有的字段。这样普通表继承 BaseEntity 不会因为缺少租户字段而出错,多租户表继承 TenantBaseEntity 时又能自动填充。
创建人字段填充
创建人字段通常命名为 createBy,数据库字段为 create_by。它表示数据最初由哪个用户创建,通常只在新增时填充,后续修改不应覆盖。
数据库字段示例:
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID'实体字段示例:
@TableField(fill = FieldFill.INSERT)
private Long createBy;2
当前用户上下文示例:
文件位置:src/main/java/io/github/atengk/framework/security/CurrentUserContext.java
package io.github.atengk.framework.security;
import cn.hutool.core.util.ObjectUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 当前用户上下文
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class CurrentUserContext {
private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>();
/**
* 设置当前登录用户
*
* @param loginUser 登录用户
*/
public void setLoginUser(LoginUser loginUser) {
USER_HOLDER.set(loginUser);
}
/**
* 获取当前登录用户
*
* @return 登录用户
*/
public LoginUser getLoginUser() {
return USER_HOLDER.get();
}
/**
* 获取当前用户 ID,未登录时返回默认值
*
* @return 用户 ID
*/
public Long getUserIdOrDefault() {
LoginUser loginUser = getLoginUser();
if (ObjectUtil.isNull(loginUser) || ObjectUtil.isNull(loginUser.getUserId())) {
return 0L;
}
return loginUser.getUserId();
}
/**
* 获取当前部门 ID,未登录时返回默认值
*
* @return 部门 ID
*/
public Long getDeptIdOrDefault() {
LoginUser loginUser = getLoginUser();
if (ObjectUtil.isNull(loginUser) || ObjectUtil.isNull(loginUser.getDeptId())) {
return 0L;
}
return loginUser.getDeptId();
}
/**
* 清理当前登录用户
*/
public void clear() {
USER_HOLDER.remove();
log.trace("当前用户上下文已清理");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
登录用户对象如下。
文件位置:src/main/java/io/github/atengk/framework/security/LoginUser.java
package io.github.atengk.framework.security;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 当前登录用户
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class LoginUser implements Serializable {
/**
* 用户 ID
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 部门 ID
*/
private Long deptId;
/**
* 租户 ID
*/
private Long tenantId;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
实际项目中,CurrentUserContext 可以从 Sa-Token、Spring Security、网关透传 Header、JWT Token 或 ThreadLocal 中获取当前用户。本示例重点是说明自动填充的接入位置。
更新人字段填充
更新人字段通常命名为 updateBy,数据库字段为 update_by。它表示最近一次修改数据的用户。新增时可以同时填充 updateBy,修改时重新填充。
数据库字段示例:
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID'实体字段示例:
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;2
更新填充逻辑:
@Override
public void updateFill(MetaObject metaObject) {
Long userId = currentUserContext.getUserIdOrDefault();
this.strictUpdateFill(metaObject, "updateBy", Long.class, userId);
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
log.debug("更新字段自动填充完成,用户ID:{}", userId);
}2
3
4
5
6
7
8
9
更新人字段注意事项:
| 场景 | 建议 |
|---|---|
| 普通接口修改 | 使用当前登录用户 |
| 定时任务修改 | 使用系统用户 ID,例如 0 |
| MQ 消费修改 | 从消息上下文或系统用户获取 |
| 异步任务修改 | 需要手动传递用户上下文 |
| 数据修复脚本 | 可使用系统用户 ID |
| 外部系统同步 | 可使用外部系统专用用户 ID |
不要让前端传入 updateBy。更新人应由服务端上下文决定。
创建时间填充
创建时间字段通常命名为 createTime,数据库字段为 create_time。它表示数据最初创建时间,只在新增时填充,不应在修改时覆盖。
数据库字段示例:
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'实体字段示例:
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;2
填充逻辑:
LocalDateTime now = LocalDateTime.now();
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);2
创建时间设计建议:
| 规范项 | 建议 |
|---|---|
| Java 类型 | LocalDateTime |
| 数据库类型 | DATETIME |
| 填充时机 | 新增时 |
| 数据库默认值 | 建议保留 CURRENT_TIMESTAMP 兜底 |
| 前端传入 | 不允许由前端控制 |
| 时区策略 | 普通国内系统统一 Asia/Shanghai |
应用层自动填充和数据库默认值可以同时存在。应用层负责业务一致性,数据库默认值负责兜底。
更新时间填充
更新时间字段通常命名为 updateTime,数据库字段为 update_time。它表示最近一次修改时间,新增和修改时都应填充。
数据库字段示例:
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'实体字段示例:
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;2
填充逻辑:
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());2
更新时间注意事项:
| 场景 | 建议 |
|---|---|
updateById | 通常可以触发自动填充 |
saveOrUpdate | 新增或修改时均可填充 |
| Wrapper 更新 | 某些场景需手动 set(updateTime) |
| XML 自定义更新 | 需要 SQL 中显式更新 update_time |
| 批量更新 | 需要确认是否触发填充 |
| 数据库兜底 | 可保留 ON UPDATE CURRENT_TIMESTAMP |
如果使用 XML 自定义更新,建议手动写入更新时间:
<update id="updateUserStatus">
UPDATE sys_user
SET status = #{status},
update_by = #{updateBy},
update_time = NOW()
WHERE id = #{id}
AND deleted = 0
</update>2
3
4
5
6
7
8
自动填充不是所有 SQL 都能覆盖,尤其是复杂 XML 更新和部分 Wrapper 更新场景,应根据实际测试确认。
部门字段填充
部门字段通常命名为 deptId,数据库字段为 dept_id。它表示数据归属部门,常用于部门维度数据权限。部门字段是否放入公共父类,需要看系统是否所有业务表都有部门归属。
数据库字段示例:
dept_id BIGINT NOT NULL DEFAULT 0 COMMENT '部门ID'实体字段示例:
@TableField(fill = FieldFill.INSERT)
private Long deptId;2
自动填充逻辑:
Long deptId = currentUserContext.getDeptIdOrDefault();
this.fillIfFieldExists(metaObject, "deptId", deptId);2
部门字段填充建议:
| 场景 | 建议 |
|---|---|
| 用户创建的业务数据 | 可以自动填充当前用户部门 |
| 系统配置表 | 通常不需要部门字段 |
| 字典表 | 视是否按部门隔离决定 |
| 订单表 | 根据业务归属决定,可能来自销售部门或下单用户部门 |
| 跨部门协作数据 | 不一定适合简单填充当前部门 |
| 导入数据 | 可由导入模板指定,或默认当前用户部门 |
部门字段虽然可以自动填充,但并非所有业务都应简单取当前用户部门。例如订单可能归属销售部门、客户部门或组织架构中的特定节点,应根据业务规则决定。
租户字段填充
租户字段通常命名为 tenantId,数据库字段为 tenant_id。它表示数据归属租户,是 SaaS 多租户系统的数据隔离基础。租户 ID 通常来自登录用户、Token、请求头、网关上下文或租户域名解析结果。
数据库字段示例:
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID'实体字段示例:
@TableField(fill = FieldFill.INSERT)
private Long tenantId;2
租户上下文示例:
文件位置:src/main/java/io/github/atengk/framework/tenant/CurrentTenantContext.java
package io.github.atengk.framework.tenant;
import cn.hutool.core.util.ObjectUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 当前租户上下文
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class CurrentTenantContext {
private static final ThreadLocal<Long> TENANT_HOLDER = new ThreadLocal<>();
/**
* 设置当前租户 ID
*
* @param tenantId 租户 ID
*/
public void setTenantId(Long tenantId) {
TENANT_HOLDER.set(tenantId);
}
/**
* 获取当前租户 ID
*
* @return 租户 ID
*/
public Long getTenantId() {
return TENANT_HOLDER.get();
}
/**
* 获取当前租户 ID,未设置时返回默认值
*
* @return 租户 ID
*/
public Long getTenantIdOrDefault() {
Long tenantId = getTenantId();
return ObjectUtil.defaultIfNull(tenantId, 0L);
}
/**
* 清理当前租户上下文
*/
public void clear() {
TENANT_HOLDER.remove();
log.trace("当前租户上下文已清理");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
自动填充逻辑:
Long tenantId = currentTenantContext.getTenantIdOrDefault();
this.fillIfFieldExists(metaObject, "tenantId", tenantId);2
在 Web 请求中设置用户和租户上下文,可以通过拦截器完成。下面示例使用 Header 模拟,实际项目应从登录态或安全框架中解析。
文件位置:src/main/java/io/github/atengk/framework/web/UserContextInterceptor.java
package io.github.atengk.framework.web;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 用户与租户上下文拦截器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class UserContextInterceptor implements HandlerInterceptor {
private final CurrentUserContext currentUserContext;
private final CurrentTenantContext currentTenantContext;
/**
* 请求进入时设置用户和租户上下文
*
* @param request 请求对象
* @param response 响应对象
* @param handler 处理器
* @return 是否继续执行
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
Long userId = Convert.toLong(request.getHeader("X-User-Id"), 0L);
Long deptId = Convert.toLong(request.getHeader("X-Dept-Id"), 0L);
Long tenantId = Convert.toLong(request.getHeader("X-Tenant-Id"), 0L);
String username = StrUtil.blankToDefault(request.getHeader("X-Username"), "system");
LoginUser loginUser = new LoginUser();
loginUser.setUserId(userId);
loginUser.setDeptId(deptId);
loginUser.setTenantId(tenantId);
loginUser.setUsername(username);
currentUserContext.setLoginUser(loginUser);
currentTenantContext.setTenantId(tenantId);
log.trace("用户与租户上下文设置完成,用户ID:{},部门ID:{},租户ID:{}", userId, deptId, tenantId);
return true;
}
/**
* 请求完成后清理上下文,避免线程复用导致数据串扰
*
* @param request 请求对象
* @param response 响应对象
* @param handler 处理器
* @param ex 异常对象
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
currentUserContext.clear();
currentTenantContext.clear();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
注册拦截器:
文件位置:src/main/java/io/github/atengk/framework/web/WebMvcConfig.java
package io.github.atengk.framework.web;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final UserContextInterceptor userContextInterceptor;
/**
* 注册 Web 拦截器
*
* @param registry 拦截器注册器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userContextInterceptor)
.addPathPatterns("/api/**");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
租户字段填充注意事项:
| 场景 | 建议 |
|---|---|
| Web 请求 | 从登录用户或请求上下文获取租户 |
| 定时任务 | 显式设置租户上下文或循环租户执行 |
| MQ 消费 | 消息体中携带租户 ID,并在消费前设置上下文 |
| 异步任务 | 需要手动传递租户上下文 |
| 平台级数据 | 可使用租户 ID 0 或不加租户字段 |
| 多租户插件 | 自动填充只负责写入,查询隔离还需要租户插件或手动条件 |
自动填充租户 ID 只解决新增数据时的归属问题,查询隔离还需要后续「多租户」章节中的 TenantLineInnerInterceptor 或数据权限控制配合。
自动填充失效排查
自动填充失效通常由实体字段配置、处理器注册、字段名称、类型不一致、使用方式不正确或自定义 SQL 绕过导致。排查时应按“实体注解 → 处理器是否注册 → 字段名和类型 → 执行方式 → 日志验证”的顺序处理。
常见失效原因如下:
| 问题 | 现象 | 处理方式 |
|---|---|---|
字段未加 fill | 字段始终不填充 | 添加 @TableField(fill = FieldFill.INSERT) 或 INSERT_UPDATE |
| 处理器未注册 | 所有字段都不填充 | 确认 MetaObjectHandler 实现类被 Spring 扫描 |
| 字段名写错 | 某个字段不填充 | strictInsertFill 中字段名必须是 Java 属性名 |
| 类型不一致 | 字段不填充或报错 | 填充值类型必须与实体字段类型一致 |
| 实体无 setter | 字段不填充 | Lombok 或手写 setter 必须存在 |
| XML 自定义更新 | 更新时间不变 | XML 中手动设置 update_time = NOW() |
| Wrapper 更新 | 部分字段不填充 | 必要时手动 .set(UserEntity::getUpdateTime, now) |
| 手动设置 null | 严格填充不覆盖 | 根据策略使用 setFieldValByName 或调整业务写法 |
| ThreadLocal 未设置 | 创建人、租户为空 | 检查拦截器、安全框架、异步上下文 |
| 异步任务 | 用户上下文丢失 | 异步执行前显式传递上下文 |
| 批量操作 | 部分字段异常 | 检查批量实体字段、类型、填充策略 |
排查实体字段:
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;2
3
4
5
排查处理器是否被扫描:
@Slf4j
@Component
public class MyBatisPlusMetaObjectHandler implements MetaObjectHandler {
}2
3
4
排查字段名是否正确:
// 正确:使用 Java 属性名 createTime
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
// 错误:不要使用数据库字段名 create_time
this.strictInsertFill(metaObject, "create_time", LocalDateTime.class, now);2
3
4
5
排查字段类型是否一致:
// 实体字段是 LocalDateTime,这里必须填充 LocalDateTime
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
// 实体字段是 Long,这里必须填充 Long
this.strictInsertFill(metaObject, "createBy", Long.class, userId);2
3
4
5
Wrapper 更新时,建议对关键字段手动设置更新时间:
boolean updated = this.lambdaUpdate()
.eq(UserEntity::getId, userId)
.set(UserEntity::getStatus, 0)
.set(UserEntity::getUpdateBy, currentUserContext.getUserIdOrDefault())
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();2
3
4
5
6
XML 更新时,建议显式写入审计字段:
<update id="updateUserStatus">
UPDATE sys_user
SET status = #{status},
update_by = #{updateBy},
update_time = NOW()
WHERE id = #{id}
AND deleted = 0
</update>2
3
4
5
6
7
8
自动填充验证方法:
@Test
void testAutoFill() {
UserEntity entity = new UserEntity();
entity.setUsername("auto_fill_test");
entity.setNickname("自动填充测试");
entity.setStatus(1);
userMapper.insert(entity);
Assertions.assertNotNull(entity.getCreateTime());
Assertions.assertNotNull(entity.getUpdateTime());
Assertions.assertNotNull(entity.getCreateBy());
Assertions.assertNotNull(entity.getUpdateBy());
}2
3
4
5
6
7
8
9
10
11
12
13
14
自动填充使用建议:
| 规范项 | 建议 |
|---|---|
| 审计字段 | 统一由 MetaObjectHandler 填充 |
| 业务字段 | 不建议放入自动填充 |
| 用户上下文 | 请求结束必须清理 ThreadLocal |
| 租户上下文 | Web、MQ、异步、定时任务都要明确设置 |
| XML SQL | 需要手动处理审计字段 |
| 单元测试 | 必须覆盖新增和修改场景 |
| 日志级别 | 自动填充日志使用 debug 或 trace |
自动填充的定位是“通用审计字段赋值”,不是业务规则引擎。订单状态、库存数量、支付金额、审核结果等业务字段必须由 Service 层明确处理,不能依赖自动填充。
逻辑删除
逻辑删除是指删除数据时不执行物理 DELETE,而是将记录标记为已删除。MyBatis-Plus 的逻辑删除会在查询时自动过滤已删除数据,在更新时防止更新已删除数据,在删除时将删除语句转换为更新语句,例如把 delete 转换为 update ... set deleted = 1 where ... and deleted = 0。逻辑删除字段支持多种数据类型,官方更推荐使用 Integer、Boolean 或 LocalDateTime。(MyBatis-Plus)
逻辑删除字段配置
逻辑删除字段建议统一命名为 deleted,数据库字段为 deleted,Java 实体属性也为 deleted。普通业务表建议使用 TINYINT 类型,其中 0 表示未删除,1 表示已删除。
数据库字段示例:
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除'完整表结构片段示例:
CREATE TABLE sys_user (
id BIGINT NOT NULL COMMENT '主键ID',
username VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_username (username),
KEY idx_deleted_create_time (deleted, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';2
3
4
5
6
7
8
9
10
11
12
13
实体字段示例:
@TableLogic
private Integer deleted;2
逻辑删除字段设计建议如下:
| 场景 | 建议 |
|---|---|
| 普通业务表 | 使用 deleted TINYINT NOT NULL DEFAULT 0 |
| 核心历史数据 | 使用逻辑删除,保留审计价值 |
| 日志表 | 通常不使用逻辑删除,按时间归档或物理清理 |
| 中间表 | 根据业务决定,重要关系建议逻辑删除 |
| 临时表 | 可以物理删除 |
| 多次删除后允许复用唯一字段 | 可以考虑 deleted_time 或删除时间戳方案 |
普通项目中,deleted 使用 0/1 最简单;如果需要解决“删除后允许重复创建同一唯一值”的问题,需要结合唯一索引重新设计,不能只靠 deleted 字段。
全局逻辑删除配置
全局配置适合项目内大多数表都使用相同逻辑删除字段的情况。配置后,实体中字段名与 logic-delete-field 对应时,MyBatis-Plus 可以统一识别逻辑删除字段。官方文档给出的全局配置项包括 logic-delete-field、logic-delete-value 和 logic-not-delete-value。(MyBatis-Plus)
文件位置:src/main/resources/application.yml
mybatis-plus:
global-config:
db-config:
# 全局逻辑删除字段名,注意这里是实体类属性名,不是数据库字段名
logic-delete-field: deleted
# 逻辑已删除值
logic-delete-value: 1
# 逻辑未删除值
logic-not-delete-value: 02
3
4
5
6
7
8
9
10
11
推荐同时在公共父类中声明字段:
文件位置:src/main/java/io/github/atengk/common/entity/BaseEntity.java
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 实体公共父类
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class BaseEntity implements Serializable {
/**
* 逻辑删除标识:0未删除,1已删除
*/
@TableLogic
private Integer deleted;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
全局配置建议:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
logic-delete-field | deleted | 实体属性名 |
logic-delete-value | 1 | 已删除 |
logic-not-delete-value | 0 | 未删除 |
| 数据库默认值 | 0 | 插入时默认未删除 |
如果项目只有少量表使用逻辑删除,也可以不配置全局字段,只在对应实体字段上使用 @TableLogic(value = "0", delval = "1")。
TableLogic 局部配置
@TableLogic 用于在实体字段上显式声明逻辑删除字段。它的 value 表示未删除值,delval 表示已删除值。官方注解文档说明,@TableLogic(value = "0", delval = "1") 可以直接指定逻辑未删除值和逻辑已删除值。(MyBatis-Plus)
实体示例:
文件位置:src/main/java/io/github/atengk/module/system/user/entity/UserEntity.java
package io.github.atengk.module.system.user.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 用户实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_user")
public class UserEntity {
/**
* 用户 ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 逻辑删除标识:0未删除,1已删除
*/
@TableLogic(value = "0", delval = "1")
private Integer deleted;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
局部配置适合以下场景:
| 场景 | 建议 |
|---|---|
| 个别表逻辑删除字段不同 | 使用 @TableLogic(value = ..., delval = ...) |
| 项目未配置全局逻辑删除 | 使用 @TableLogic |
| 某些表使用时间字段删除 | 使用局部配置并单独测试 |
| 多个模块规范不一致 | 优先统一规范,不建议长期混用 |
如果全局配置和局部注解同时存在,建议以局部注解表达特殊表规则,普通表走全局配置。
查询过滤规则
逻辑删除生效后,MyBatis-Plus 自动注入的查询会追加未删除条件。例如调用 selectById、selectList、lambdaQuery().list() 等方法时,会过滤 deleted = 1 的数据。官方文档说明,逻辑删除对自动注入 SQL 生效:查询会自动过滤已删除数据,更新会防止更新已删除数据,删除会转换为更新。(MyBatis-Plus)
Service 查询示例:
public UserEntity getUserById(Long id) {
return this.getById(id);
}2
3
逻辑上等价于:
SELECT id, username, nickname, deleted
FROM sys_user
WHERE id = ?
AND deleted = 0;2
3
4
删除示例:
public void deleteUser(Long id) {
boolean removed = this.removeById(id);
if (!removed) {
throw new BusinessException("用户删除失败");
}
}2
3
4
5
6
逻辑上等价于:
UPDATE sys_user
SET deleted = 1
WHERE id = ?
AND deleted = 0;2
3
4
查询过滤注意事项:
| 场景 | 是否自动过滤 |
|---|---|
BaseMapper.selectById | 是 |
BaseMapper.selectList | 是 |
Service.getById | 是 |
Service.lambdaQuery() | 是 |
Service.removeById | 转换为逻辑删除 |
| XML 自定义 SQL | 不一定,需要自己确认 |
| 注解 SQL | 不一定,需要自己确认 |
| 手写 SQL | 不会自动保证,必须显式处理 |
自定义 XML SQL 中建议显式写出 deleted = 0,不要假设所有自定义 SQL 都能按预期追加逻辑删除条件。
<select id="selectUserPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
id,
username,
nickname,
status,
create_time
FROM sys_user
WHERE deleted = 0
<if test="query.username != null and query.username != ''">
AND username LIKE CONCAT('%', #{query.username}, '%')
</if>
ORDER BY create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
恢复已删除数据
恢复已删除数据是指将 deleted = 1 的记录重新改为 deleted = 0。由于 MyBatis-Plus 的普通查询默认过滤已删除数据,恢复操作通常需要使用自定义 SQL,或者使用特殊 Mapper 方法显式操作逻辑删除字段。
Mapper 方法:
文件位置:src/main/java/io/github/atengk/module/system/user/mapper/UserMapper.java
package io.github.atengk.module.system.user.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.atengk.module.system.user.entity.UserEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
/**
* 用户 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserMapper extends BaseMapper<UserEntity> {
/**
* 恢复已逻辑删除用户
*
* @param id 用户 ID
* @return 影响行数
*/
@Update("""
UPDATE sys_user
SET deleted = 0,
update_time = NOW()
WHERE id = #{id}
AND deleted = 1
""")
int restoreDeletedById(@Param("id") Long id);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Service 示例:
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserLogicDeleteService.java
package io.github.atengk.module.system.user.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 用户逻辑删除服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class UserLogicDeleteService extends ServiceImpl<UserMapper, UserEntity> {
/**
* 恢复已删除用户
*
* @param id 用户 ID
*/
@Transactional(rollbackFor = Exception.class)
public void restoreUser(Long id) {
if (id == null) {
throw new BusinessException("用户ID不能为空");
}
int rows = baseMapper.restoreDeletedById(id);
if (rows <= 0) {
throw new BusinessException("用户不存在或未处于删除状态");
}
log.info("恢复已删除用户成功,用户ID:{}", id);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
恢复数据注意事项:
| 检查项 | 建议 |
|---|---|
| 唯一约束 | 恢复前检查唯一字段是否冲突 |
| 数据权限 | 只能恢复有权限的数据 |
| 关联数据 | 恢复前确认关联数据是否仍有效 |
| 操作日志 | 恢复属于高风险操作,应记录 |
| 业务语义 | 不是所有删除数据都允许恢复 |
如果删除后允许重新创建同名数据,恢复时很容易遇到唯一约束冲突。因此恢复接口必须先检查唯一索引相关字段。
物理删除场景
物理删除是指真正执行 DELETE FROM table WHERE ...。核心业务表一般不建议物理删除,但临时数据、任务中间表、缓存表、导入失败明细表、过期日志表可以考虑物理删除。
常见物理删除场景:
| 场景 | 是否适合物理删除 |
|---|---|
| 临时表数据 | 适合 |
| 导入中间表 | 适合 |
| 任务执行临时结果 | 适合 |
| 缓存落库数据 | 适合 |
| 操作日志 | 可以归档后物理清理 |
| 用户、订单、支付数据 | 不建议 |
| 审批、合同、财务数据 | 不建议 |
物理删除建议使用 XML 或专门 Mapper 方法,避免误用普通 removeById。
Mapper 示例:
/**
* 物理删除指定时间之前的临时数据
*
* @param beforeTime 截止时间
* @return 影响行数
*/
int physicalDeleteTempBefore(@Param("beforeTime") LocalDateTime beforeTime);2
3
4
5
6
7
XML 示例:
<delete id="physicalDeleteTempBefore">
DELETE FROM tmp_import_record
WHERE create_time < #{beforeTime}
</delete>2
3
4
物理删除 Service 示例:
@Transactional(rollbackFor = Exception.class)
public int clearExpiredTempData(LocalDateTime beforeTime) {
if (beforeTime == null) {
throw new BusinessException("清理截止时间不能为空");
}
int rows = tempRecordMapper.physicalDeleteTempBefore(beforeTime);
log.info("物理清理过期临时数据完成,截止时间:{},数量:{}", beforeTime, rows);
return rows;
}2
3
4
5
6
7
8
9
10
物理删除注意事项:
| 规范项 | 建议 |
|---|---|
| 条件 | 必须有明确 WHERE 条件 |
| 数量 | 大批量删除要分批执行 |
| 事务 | 避免超大事务 |
| 备份 | 核心数据物理删除前必须备份 |
| 审计 | 记录操作人、条件和数量 |
| 权限 | 仅管理员或任务账号可执行 |
唯一索引与逻辑删除冲突处理
逻辑删除最常见的问题是唯一索引冲突。例如 username 有唯一索引,用户被逻辑删除后,如果再次创建同名用户,仍然会被旧记录拦截。
冲突示例:
UNIQUE KEY uk_username (username)当存在以下数据时:
id=1, username=admin, deleted=1再次插入:
username=admin, deleted=0仍会违反 uk_username。
常见处理方案如下:
| 方案 | 说明 | 适用场景 |
|---|---|---|
| 不允许复用唯一字段 | 只对业务字段建唯一索引 | 用户名、订单号、支付单号等强唯一场景 |
联合唯一索引加入 deleted | UNIQUE(username, deleted) | 只允许一条删除记录时可用 |
| 删除时改写唯一字段 | 删除时把 username 改为 username#deleted#id | 后台管理系统可用 |
| 使用删除时间戳字段 | 未删除为 0,删除为时间戳 | 支持同一唯一值多次删除和重建 |
| 物理删除 | 真删除后释放唯一值 | 非核心数据 |
普通 UNIQUE(username, deleted) 有一个隐藏问题:同一个用户名如果多次删除,所有已删除记录的 deleted 都是 1,第二次删除或插入历史数据时仍可能冲突。
更稳妥的时间戳方案示例:
deleted_at BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间戳:0未删除,非0已删除时间戳',
UNIQUE KEY uk_username_deleted_at (username, deleted_at)2
逻辑删除配置可以设计为:
mybatis-plus:
global-config:
db-config:
# 使用 deletedAt 作为逻辑删除字段时,这里写 Java 属性名
logic-delete-field: deletedAt
logic-not-delete-value: 0
logic-delete-value: UNIX_TIMESTAMP()2
3
4
5
6
7
官方文档也提到,如果使用 bigint 类型逻辑删除字段,可以将未删除值配置为 0,已删除值使用 UNIX_TIMESTAMP(),该方式适合把删除字段作为唯一索引组成列,从而支持多次逻辑删除。(MyBatis-Plus)
实体字段示例:
@TableLogic(value = "0", delval = "UNIX_TIMESTAMP()")
private Long deletedAt;2
是否采用时间戳方案,要在项目早期确定。普通后台系统可以继续使用 deleted 方案;对“删除后允许重复创建同一编码、同一名称”的业务,应优先考虑 deleted_at 或删除时改写唯一字段。
逻辑删除注意事项
逻辑删除不是状态字段。官方文档也提醒,如果业务中仍需频繁查询这些“已删除”数据,应该考虑是否真正需要逻辑删除,或者改用状态字段表达数据可见性。(MyBatis-Plus)
逻辑删除使用建议:
| 规范项 | 建议 |
|---|---|
| 业务含义 | 逻辑删除应等同于业务上的删除 |
| 查询已删除 | 仅用于恢复、审计、管理后台特殊功能 |
| 状态流转 | 不要用 deleted 表示禁用、冻结、过期 |
| 唯一约束 | 必须提前设计 |
| 自定义 SQL | 显式补充 deleted = 0 |
| 删除审计 | 删除操作应记录日志 |
| 大表清理 | 长期逻辑删除会增加表体积,需要归档策略 |
| 关联数据 | 删除主表前检查关联数据 |
| 数据恢复 | 恢复前检查唯一约束和业务有效性 |
典型错误做法:
deleted = 0 表示正常
deleted = 1 表示禁用
deleted = 2 表示已删除
deleted = 3 表示冻结2
3
4
这种设计混淆了删除和业务状态。正确做法是:
deleted:只表示是否删除
status:表示启用、禁用、冻结等业务状态2
乐观锁
乐观锁用于解决并发更新覆盖问题。典型流程是:读取数据时拿到版本号,更新时携带旧版本号,SQL 更新条件中追加 version = oldVersion,更新成功后版本号递增;如果版本号不匹配,则更新影响行数为 0,表示数据已被其他事务修改。MyBatis-Plus 提供 OptimisticLockerInnerInterceptor 插件,并通过实体字段上的 @Version 注解识别乐观锁字段。(MyBatis-Plus)
Version 字段设计
乐观锁字段建议统一命名为 version,数据库字段为 version,类型使用 INT 或 BIGINT。普通业务表使用 INT NOT NULL DEFAULT 0 即可。
数据库字段示例:
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'实体字段示例:
@Version
private Integer version;2
公共父类示例:
文件位置:src/main/java/io/github/atengk/common/entity/BaseEntity.java
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 实体公共父类
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class BaseEntity implements Serializable {
/**
* 乐观锁版本号
*/
@Version
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
MyBatis-Plus 乐观锁插件支持的版本字段类型包括 int、Integer、long、Long、Date、Timestamp、LocalDateTime;对于整数类型,新版本通常是旧版本加一,并且新版本会自动回写到实体对象中。(MyBatis-Plus)
版本字段设计建议:
| 场景 | 推荐类型 |
|---|---|
| 普通业务表 | Integer |
| 高频更新表 | Long |
| 需要时间版本 | LocalDateTime,但要严格测试 |
| 简单后台管理 | Integer 足够 |
| 只新增不修改的流水表 | 通常不需要乐观锁 |
乐观锁适用于“并发修改冲突较少,但必须防止覆盖”的场景。如果冲突非常频繁,说明业务模型可能需要数据库条件更新、悲观锁、队列化处理或专门并发控制方案。
OptimisticLockerInnerInterceptor 配置
乐观锁需要注册 OptimisticLockerInnerInterceptor 插件,并在实体字段上添加 @Version。官方文档给出的 Spring Boot 配置方式是将 OptimisticLockerInnerInterceptor 添加到 MybatisPlusInterceptor 中。(MyBatis-Plus)
文件位置:src/main/java/io/github/atengk/framework/mybatis/MyBatisPlusConfig.java
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 配置
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Configuration
public class MyBatisPlusConfig {
/**
* 配置 MyBatis-Plus 拦截器
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 乐观锁插件,用于处理 @Version 字段
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 分页插件,单库项目建议显式指定数据库类型
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
log.info("MyBatis-Plus插件初始化完成,已启用乐观锁和分页插件");
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
实体示例:
文件位置:src/main/java/io/github/atengk/module/system/user/entity/UserEntity.java
package io.github.atengk.module.system.user.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
/**
* 用户实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_user")
public class UserEntity {
/**
* 用户 ID
*/
private Long id;
/**
* 昵称
*/
private String nickname;
/**
* 乐观锁版本号
*/
@Version
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
乐观锁常见生效方法包括内置的 updateById(entity)、update(entity, wrapper)、saveOrUpdate(entity),并且官方文档提醒:在 update(entity, wrapper) 方法中,wrapper 不能复用。(MyBatis-Plus)
更新冲突处理
乐观锁冲突通常表现为更新返回 false 或影响行数为 0。业务上应将其转换为明确提示,例如“数据已被其他用户修改,请刷新后重试”。
推荐更新流程:
- 查询当前数据。
- 校验业务状态。
- 合并修改字段。
- 调用
updateById(entity)。 - 如果返回
false,抛出乐观锁冲突异常。
业务异常状态码可以扩展一个 DATA_VERSION_CONFLICT:
文件位置:src/main/java/io/github/atengk/common/result/ResultCode.java
package io.github.atengk.common.result;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 统一响应状态码
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum ResultCode {
SUCCESS(200, "操作成功"),
BUSINESS_ERROR(5001, "业务处理失败"),
DATA_NOT_FOUND(5002, "数据不存在"),
DATA_DUPLICATE(5003, "数据已存在"),
DATA_VERSION_CONFLICT(5006, "数据已被修改,请刷新后重试");
private final Integer code;
private final String message;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Service 示例:
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserOptimisticLockService.java
package io.github.atengk.module.system.user.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.common.result.ResultCode;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 用户乐观锁服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class UserOptimisticLockService extends ServiceImpl<UserMapper, UserEntity> {
/**
* 使用乐观锁修改用户
*
* @param dto 用户修改参数
*/
@Transactional(rollbackFor = Exception.class)
public void updateUserWithVersion(UserUpdateDTO dto) {
if (dto.getId() == null) {
throw new BusinessException("用户ID不能为空");
}
if (dto.getVersion() == null) {
throw new BusinessException("版本号不能为空");
}
UserEntity entity = this.getById(dto.getId());
if (entity == null) {
throw new BusinessException(ResultCode.DATA_NOT_FOUND, "用户不存在");
}
entity.setNickname(dto.getNickname());
entity.setPhone(dto.getPhone());
entity.setEmail(dto.getEmail());
// 关键:使用前端或调用方提交的旧版本号参与更新条件
entity.setVersion(dto.getVersion());
boolean updated = this.updateById(entity);
if (!updated) {
throw new BusinessException(ResultCode.DATA_VERSION_CONFLICT);
}
log.info("乐观锁修改用户成功,用户ID:{},新版本号:{}", entity.getId(), entity.getVersion());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
更新时,前端需要在详情接口中拿到 version,提交修改时带回。详情 VO 示例:
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
/**
* 用户详情返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserDetailVO {
/**
* 用户 ID
*/
private Long id;
/**
* 昵称
*/
private String nickname;
/**
* 乐观锁版本号
*/
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
修改 DTO 示例:
package io.github.atengk.module.system.user.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
/**
* 用户修改参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserUpdateDTO {
/**
* 用户 ID
*/
@NotNull(message = "用户ID不能为空")
private Long id;
/**
* 昵称
*/
private String nickname;
/**
* 手机号
*/
private String phone;
/**
* 邮箱
*/
private String email;
/**
* 乐观锁版本号
*/
@NotNull(message = "版本号不能为空")
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
更新冲突处理建议:
| 场景 | 建议 |
|---|---|
| 后台表单编辑 | 返回“数据已被修改,请刷新后重试” |
| 状态流转 | 返回明确状态冲突提示 |
| 库存扣减 | 不只依赖乐观锁,应使用条件更新或库存流水 |
| 审批处理 | 冲突后要求重新加载审批状态 |
| 配置保存 | 冲突后要求重新加载最新配置 |
| 自动任务 | 可有限重试 |
乐观锁冲突不是系统异常,通常属于可预期业务冲突,日志级别建议使用 warn 或业务日志记录。
并发修改场景
乐观锁适合并发修改同一条数据且冲突概率较低的场景。它不会阻塞其他事务,而是在更新时检查版本号是否一致。
适合使用乐观锁的场景:
| 场景 | 说明 |
|---|---|
| 用户资料修改 | 防止两个管理员同时修改覆盖 |
| 系统配置修改 | 防止旧配置覆盖新配置 |
| 订单状态流转 | 防止重复确认、重复取消 |
| 审批处理 | 防止多人同时审批同一单据 |
| 字典配置维护 | 防止覆盖最新配置 |
| 商品信息维护 | 防止后台同时编辑 |
不适合单独依赖乐观锁的场景:
| 场景 | 原因 |
|---|---|
| 秒杀库存扣减 | 冲突频繁,重试成本高 |
| 账户余额变更 | 需要流水、幂等和严格一致性 |
| 高频计数器 | 乐观锁冲突过多 |
| 消息消费偏移量 | 需要幂等和状态机 |
| 大批量更新 | 版本处理复杂 |
库存扣减更推荐使用条件更新:
boolean updated = productService.lambdaUpdate()
.eq(ProductEntity::getId, productId)
.ge(ProductEntity::getStockCount, quantity)
.setSql("stock_count = stock_count - " + quantity)
.update();
if (!updated) {
throw new BusinessException("库存不足");
}2
3
4
5
6
7
8
9
上面示例中的 quantity 必须由后端校验为正整数,不能直接拼接前端未校验参数。更严谨的库存扣减建议使用 XML 参数绑定或专门库存服务。
订单状态流转示例:
@Transactional(rollbackFor = Exception.class)
public void confirmOrder(Long orderId, Integer version) {
OrderEntity order = orderService.getById(orderId);
if (order == null) {
throw new BusinessException("订单不存在");
}
if (!OrderStatusEnum.PENDING_CONFIRM.equals(order.getStatus())) {
throw new BusinessException("当前订单状态不允许确认");
}
order.setStatus(OrderStatusEnum.CONFIRMED);
order.setVersion(version);
boolean updated = orderService.updateById(order);
if (!updated) {
throw new BusinessException(ResultCode.DATA_VERSION_CONFLICT, "订单已被其他操作修改,请刷新后重试");
}
log.info("订单确认成功,订单ID:{}", orderId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
重试机制设计
乐观锁冲突后是否重试,要看业务语义。用户编辑表单、审批操作、状态变更通常不应自动重试,而应提示用户刷新;自动任务、异步计算、低风险计数类任务可以有限重试。
重试适用性:
| 场景 | 是否建议重试 |
|---|---|
| 后台用户编辑 | 不建议,提示刷新 |
| 审批处理 | 不建议,状态必须重新确认 |
| 订单状态流转 | 谨慎,通常不自动重试 |
| 定时任务修正统计 | 可以有限重试 |
| 异步任务更新辅助字段 | 可以有限重试 |
| 低价值计数 | 可以有限重试或改用原子更新 |
通用重试示例:
@Transactional(rollbackFor = Exception.class)
public void updateUserNicknameWithRetry(Long userId, String nickname) {
int maxRetry = 3;
for (int i = 1; i <= maxRetry; i++) {
UserEntity entity = this.getById(userId);
if (entity == null) {
throw new BusinessException("用户不存在");
}
entity.setNickname(nickname);
boolean updated = this.updateById(entity);
if (updated) {
log.info("修改用户昵称成功,用户ID:{},重试次数:{}", userId, i - 1);
return;
}
log.warn("修改用户昵称发生版本冲突,准备重试,用户ID:{},当前次数:{}", userId, i);
}
throw new BusinessException(ResultCode.DATA_VERSION_CONFLICT, "数据修改冲突,请稍后重试");
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
重试设计注意事项:
| 规范项 | 建议 |
|---|---|
| 最大次数 | 必须限制,例如 3 次 |
| 间隔 | 高频冲突场景可加入短暂退避 |
| 业务校验 | 每次重试都要重新读取数据并重新校验 |
| 用户编辑 | 不建议静默重试 |
| 日志 | 记录冲突次数和业务 ID |
| 事务 | 注意重试循环与事务边界 |
如果所有请求都频繁发生乐观锁冲突,应重新评估业务模型,不要简单提高重试次数。
乐观锁失效排查
乐观锁失效通常表现为:并发修改时没有检测冲突、版本号没有递增、更新返回成功但覆盖了其他人的修改、或者版本字段没有回写。
常见失效原因如下:
| 原因 | 现象 | 处理方式 |
|---|---|---|
| 未配置拦截器 | version 不参与 SQL | 注册 OptimisticLockerInnerInterceptor |
实体字段未加 @Version | 版本字段无效 | 在版本字段添加 @Version |
| DTO 未携带版本号 | 无法判断旧版本 | 修改接口要求传 version |
| 更新前重新查询覆盖版本 | 永远使用最新版本 | 用调用方提交的旧版本号参与更新 |
| 使用 XML 自定义更新 | 插件可能不处理 | 手写 version = version + 1 和 version = #{version} 条件 |
| Wrapper 复用 | 更新行为异常 | 每次更新新建 Wrapper |
| 字段类型不支持 | 插件不生效 | 使用官方支持类型 |
| 批量更新 | 无法逐条处理版本 | 批量更新要单独设计 |
| 前端未刷新 | 反复提交旧版本 | 提示刷新最新数据 |
乐观锁正确 SQL 逻辑应类似:
UPDATE sys_user
SET nickname = ?,
version = version + 1
WHERE id = ?
AND version = ?
AND deleted = 0;2
3
4
5
6
XML 自定义更新时,可以手动实现乐观锁:
Mapper 方法:
/**
* 使用乐观锁修改用户昵称
*
* @param id 用户 ID
* @param nickname 昵称
* @param version 旧版本号
* @return 影响行数
*/
int updateNicknameWithVersion(@Param("id") Long id,
@Param("nickname") String nickname,
@Param("version") Integer version);2
3
4
5
6
7
8
9
10
11
XML:
<update id="updateNicknameWithVersion">
UPDATE sys_user
SET nickname = #{nickname},
version = version + 1,
update_time = NOW()
WHERE id = #{id}
AND version = #{version}
AND deleted = 0
</update>2
3
4
5
6
7
8
9
Service:
@Transactional(rollbackFor = Exception.class)
public void updateNicknameByXml(Long id, String nickname, Integer version) {
if (id == null) {
throw new BusinessException("用户ID不能为空");
}
if (version == null) {
throw new BusinessException("版本号不能为空");
}
int rows = baseMapper.updateNicknameWithVersion(id, nickname, version);
if (rows <= 0) {
throw new BusinessException(ResultCode.DATA_VERSION_CONFLICT);
}
log.info("XML乐观锁修改用户昵称成功,用户ID:{}", id);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
乐观锁测试示例:
文件位置:src/test/java/io/github/atengk/module/system/user/UserOptimisticLockTest.java
package io.github.atengk.module.system.user;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* 用户乐观锁测试
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@SpringBootTest
@ActiveProfiles("test")
@MapperScan("io.github.atengk.**.mapper")
class UserOptimisticLockTest {
@Autowired
private UserService userService;
/**
* 测试乐观锁冲突
*/
@Test
void testOptimisticLockConflict() {
UserEntity user = new UserEntity();
user.setUsername("version_test");
user.setNickname("乐观锁测试");
userService.save(user);
UserEntity userA = userService.getById(user.getId());
UserEntity userB = userService.getById(user.getId());
userA.setNickname("用户A修改");
boolean updatedA = userService.updateById(userA);
Assertions.assertTrue(updatedA);
userB.setNickname("用户B修改");
boolean updatedB = userService.updateById(userB);
Assertions.assertFalse(updatedB);
log.info("乐观锁冲突测试完成,用户ID:{}", user.getId());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
乐观锁排查建议:
| 排查项 | 检查方式 |
|---|---|
| 插件是否注册 | 查看配置类和启动日志 |
| 字段是否注解 | 检查 @Version |
| SQL 是否带版本条件 | 开启开发环境 SQL 日志 |
| 版本是否回写 | 更新后打印实体版本号 |
| DTO 是否传版本 | 检查接口请求参数 |
| XML 是否手写版本条件 | 检查 Mapper XML |
| Wrapper 是否复用 | 每次更新新建 Wrapper |
| 批量更新是否绕过 | 单独设计批量版本控制 |
乐观锁不是替代事务的机制。它解决的是并发覆盖问题,事务解决的是一组数据库操作的原子性问题。订单、库存、账户、审批等核心场景通常需要“事务 + 状态校验 + 乐观锁或条件更新 + 幂等控制”组合使用。
枚举处理
枚举适合表达稳定、有限、低频变化的业务状态,例如启用状态、订单状态、审核状态、支付状态、性别、数据类型等。MyBatis-Plus 支持通过 @EnumValue 或实现 IEnum 指定枚举入库值;未声明的普通枚举则会走 MyBatis 默认枚举处理逻辑,默认通常按枚举常量名处理。MyBatis-Plus 官方文档说明,其提供的 MybatisEnumTypeHandler 支持基于枚举属性映射,声明方式包括 @EnumValue 和 IEnum#getValue()。(MyBatis-Plus)
普通枚举字段
普通枚举字段是指实体类中直接使用枚举类型,例如 UserStatusEnum status。这种方式比直接使用 Integer status 更有语义,也能减少魔法值散落在业务代码中。
数据库字段示例:
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用'实体字段示例:
/**
* 用户状态
*/
private UserStatusEnum status;2
3
4
普通枚举类示例:
文件位置:src/main/java/io/github/atengk/module/system/user/enums/UserStatusEnum.java
package io.github.atengk.module.system.user.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 用户状态枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum UserStatusEnum {
/**
* 禁用
*/
DISABLED(0, "禁用"),
/**
* 启用
*/
ENABLED(1, "启用");
/**
* 状态编码
*/
private final Integer code;
/**
* 状态名称
*/
private final String name;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
如果只是这样定义普通枚举,但没有使用 @EnumValue 或 IEnum,MyBatis-Plus 不一定会按 code 入库。普通枚举可能按枚举名称入库,例如 ENABLED,这通常不是业务系统期望的存储方式。因此实际项目中不建议只定义普通枚举,应明确声明枚举入库字段。
IEnum 使用
IEnum<T> 是 MyBatis-Plus 提供的枚举映射接口。枚举实现 IEnum<T> 后,通过 getValue() 返回真正写入数据库的值。官方文档将实现 IEnum 作为自动枚举映射的一种声明方式。(MyBatis-Plus)
文件位置:src/main/java/io/github/atengk/module/system/order/enums/OrderStatusEnum.java
package io.github.atengk.module.system.order.enums;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.annotation.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 订单状态枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum OrderStatusEnum implements IEnum<Integer> {
/**
* 待支付
*/
WAIT_PAY(10, "待支付"),
/**
* 已支付
*/
PAID(20, "已支付"),
/**
* 已发货
*/
SHIPPED(30, "已发货"),
/**
* 已完成
*/
FINISHED(40, "已完成"),
/**
* 已取消
*/
CANCELED(90, "已取消");
/**
* 数据库存储值
*/
private final Integer code;
/**
* 展示名称
*/
private final String name;
/**
* 获取数据库存储值
*
* @return 状态编码
*/
@Override
public Integer getValue() {
return this.code;
}
/**
* 根据编码获取枚举
*
* @param code 状态编码
* @return 订单状态枚举
*/
public static OrderStatusEnum ofCode(Integer code) {
if (ObjectUtil.isNull(code)) {
return null;
}
return Arrays.stream(values())
.filter(item -> ObjectUtil.equals(item.getCode(), code))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("订单状态不存在:" + code));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
实体中使用:
/**
* 订单状态
*/
private OrderStatusEnum status;2
3
4
IEnum 适合团队希望所有枚举都通过统一接口暴露入库值的场景。它的优点是语义明确,缺点是每个枚举都要实现接口。
EnumValue 使用
@EnumValue 用于标记枚举中真正入库的字段。官方注解文档说明,当实体字段是枚举类型时,@EnumValue 会告诉 MyBatis-Plus 使用枚举中的哪个属性保存到数据库,而不是保存枚举常量本身。(MyBatis-Plus)
文件位置:src/main/java/io/github/atengk/module/system/user/enums/UserStatusEnum.java
package io.github.atengk.module.system.user.enums;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.annotation.EnumValue;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 用户状态枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum UserStatusEnum {
/**
* 禁用
*/
DISABLED(0, "禁用"),
/**
* 启用
*/
ENABLED(1, "启用");
/**
* 数据库存储值
*/
@EnumValue
private final Integer code;
/**
* 展示名称
*/
private final String name;
/**
* 根据编码获取枚举
*
* @param code 状态编码
* @return 用户状态枚举
*/
public static UserStatusEnum ofCode(Integer code) {
if (ObjectUtil.isNull(code)) {
return null;
}
return Arrays.stream(values())
.filter(item -> ObjectUtil.equals(item.getCode(), code))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("用户状态不存在:" + code));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
实体字段:
/**
* 用户状态
*/
private UserStatusEnum status;2
3
4
数据库中保存:
0 或 1@EnumValue 使用建议:
| 规范项 | 建议 |
|---|---|
| 入库字段 | 推荐使用 code |
| 字段类型 | 与数据库字段类型保持一致 |
| 展示字段 | 使用 name 或 label |
| 编码含义 | 一旦入库禁止随意修改 |
| 删除枚举 | 不建议直接删除历史编码 |
| 新增枚举 | 可以追加,不要复用旧编码 |
@EnumValue 是最常用、最直观的枚举入库方式。普通业务系统推荐优先使用它。
枚举序列化
枚举序列化指接口返回 JSON 时枚举如何展示。默认情况下,Jackson 可能把枚举序列化为枚举名称,例如 ENABLED。业务接口更常见的做法是返回 status 和 statusName 两个字段,而不是直接返回枚举对象。
推荐 VO 写法:
文件位置:src/main/java/io/github/atengk/module/system/user/vo/UserPageVO.java
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户分页返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserPageVO {
/**
* 用户 ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 状态编码
*/
private Integer status;
/**
* 状态名称
*/
private String statusName;
/**
* 创建时间
*/
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
MapStruct 转换示例:
/**
* 获取状态编码
*
* @param status 用户状态
* @return 状态编码
*/
default Integer toStatusCode(UserStatusEnum status) {
return status == null ? null : status.getCode();
}
/**
* 获取状态名称
*
* @param status 用户状态
* @return 状态名称
*/
default String toStatusName(UserStatusEnum status) {
return status == null ? null : status.getName();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
如果确实希望枚举自身序列化为对象,可以使用 Jackson 注解,但普通后台接口不建议这样做,因为前端处理不如扁平字段稳定。
@JsonValue
public Integer getCode() {
return code;
}2
3
4
@JsonValue 会让整个枚举序列化为一个值,适合简单场景;如果需要同时返回编码和名称,仍建议在 VO 中显式定义两个字段。
枚举反序列化
枚举反序列化指接口入参中前端传入状态编码时,后端如何转成枚举。最稳妥的做法是 DTO 中接收 Integer status,在 Service 或 Convert 层转换成枚举。
DTO 示例:
/**
* 状态:0禁用,1启用
*/
@NotNull(message = "用户状态不能为空")
private Integer status;2
3
4
5
Service 转换示例:
UserStatusEnum status = UserStatusEnum.ofCode(dto.getStatus());
entity.setStatus(status);2
如果希望 DTO 直接接收枚举,可以自定义 Jackson 反序列化逻辑,但会增加全局行为复杂度。普通业务系统推荐 DTO 接收编码,Service 层显式转换。
反序列化建议:
| 方案 | 建议 |
|---|---|
DTO 接收 Integer | 推荐,接口语义清晰 |
| Service 转枚举 | 推荐,便于业务校验 |
| DTO 直接接收枚举 | 可用,但要配置反序列化 |
| 前端传枚举名称 | 不推荐,枚举名称属于后端实现细节 |
| 前端传中文名称 | 禁止,名称可能变更或国际化 |
错误编码必须给出明确提示,例如“用户状态不存在:3”,不要默默转成 null。
枚举入库策略
枚举入库策略应在项目早期统一。常见策略包括数字编码、字符串编码、枚举名称、枚举序号。推荐业务系统使用数字编码或稳定字符串编码,不建议使用枚举序号。
常见策略对比:
| 策略 | 示例 | 是否推荐 | 说明 |
|---|---|---|---|
| 数字编码 | 1 | 推荐 | 存储紧凑,适合状态字段 |
| 字符串编码 | ENABLE | 可用 | 可读性好,占用空间更大 |
| 枚举名称 | ENABLED | 谨慎 | 后端重构枚举名会影响数据 |
| 枚举序号 | 0、1 | 不推荐 | 枚举顺序变化会导致历史数据错乱 |
推荐数字编码示例:
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用'枚举:
DISABLED(0, "禁用"),
ENABLED(1, "启用");2
如果业务系统强调数据库可读性,也可以使用字符串编码:
status VARCHAR(32) NOT NULL DEFAULT 'ENABLED' COMMENT '状态编码'枚举:
DISABLED("DISABLED", "禁用"),
ENABLED("ENABLED", "启用");2
枚举入库策略建议:
| 场景 | 建议 |
|---|---|
| 状态字段 | 数字编码 |
| 类型字段 | 数字编码或字符串编码 |
| 外部系统对接 | 使用外部协议编码 |
| 可配置字典 | 不用枚举,改用字典表 |
| 高频查询字段 | 数字编码更适合索引 |
| 历史兼容要求高 | 禁止修改已入库编码含义 |
枚举回显处理
枚举回显是指接口返回时,既返回枚举编码,也返回展示名称。前端列表页、详情页和导出通常都需要回显名称。
VO 示例:
/**
* 状态编码
*/
private Integer status;
/**
* 状态名称
*/
private String statusName;2
3
4
5
6
7
8
9
转换示例:
UserStatusEnum status = entity.getStatus();
vo.setStatus(status == null ? null : status.getCode());
vo.setStatusName(status == null ? null : status.getName());2
3
完整转换器示例:
文件位置:src/main/java/io/github/atengk/module/system/user/convert/UserConvert.java
package io.github.atengk.module.system.user.convert;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.enums.UserStatusEnum;
import io.github.atengk.module.system.user.vo.UserPageVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
/**
* 用户对象转换器
*
* @author Ateng
* @since 2026-05-05
*/
@Mapper(componentModel = "spring")
public interface UserConvert {
/**
* 实体转分页返回对象
*
* @param entity 用户实体
* @return 用户分页返回对象
*/
@Mapping(target = "status", expression = "java(toStatusCode(entity.getStatus()))")
@Mapping(target = "statusName", expression = "java(toStatusName(entity.getStatus()))")
UserPageVO toPageVO(UserEntity entity);
/**
* 获取状态编码
*
* @param status 用户状态
* @return 状态编码
*/
default Integer toStatusCode(UserStatusEnum status) {
return status == null ? null : status.getCode();
}
/**
* 获取状态名称
*
* @param status 用户状态
* @return 状态名称
*/
default String toStatusName(UserStatusEnum status) {
return status == null ? null : status.getName();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
枚举回显建议:
| 场景 | 建议 |
|---|---|
| 列表接口 | 返回编码和名称 |
| 详情接口 | 返回编码和名称 |
| 导出接口 | 导出名称 |
| 新增修改接口 | 入参传编码 |
| 前端下拉 | 可提供枚举选项接口 |
| 国际化 | 不直接使用枚举中文名,改由字典或国际化资源处理 |
枚举字典转换
枚举和字典的区别在于:枚举适合稳定、开发期定义的值;字典适合运营可配置、运行期维护的值。项目中常见做法是稳定状态用枚举,页面回显可转换成统一选项结构。
通用选项 VO:
文件位置:src/main/java/io/github/atengk/common/vo/OptionVO.java
package io.github.atengk.common.vo;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
/**
* 通用选项返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class OptionVO<T> implements Serializable {
/**
* 选项值
*/
private T value;
/**
* 选项标签
*/
private String label;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
枚举转选项:
文件位置:src/main/java/io/github/atengk/module/system/user/support/UserEnumOptions.java
package io.github.atengk.module.system.user.support;
import io.github.atengk.common.vo.OptionVO;
import io.github.atengk.module.system.user.enums.UserStatusEnum;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
/**
* 用户枚举选项
*
* @author Ateng
* @since 2026-05-05
*/
@Component
public class UserEnumOptions {
/**
* 获取用户状态选项
*
* @return 用户状态选项列表
*/
public List<OptionVO<Integer>> listUserStatusOptions() {
return Arrays.stream(UserStatusEnum.values())
.map(item -> new OptionVO<>(item.getCode(), item.getName()))
.toList();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Controller 示例:
@GetMapping("/status-options")
public ApiResult<List<OptionVO<Integer>>> listUserStatusOptions() {
return ApiResult.success(userEnumOptions.listUserStatusOptions());
}2
3
4
枚举字典转换建议:
| 场景 | 方案 |
|---|---|
| 启用/禁用 | 枚举 |
| 订单状态 | 枚举 |
| 支付状态 | 枚举 |
| 性别 | 枚举或字典,视项目而定 |
| 民族、地区 | 字典 |
| 商品分类 | 数据库表 |
| 业务标签 | 字典或配置表 |
| 国际化名称 | 字典或国际化资源 |
不要把所有枚举都做成数据库字典,也不要把运营需要动态维护的值写死成枚举。
主键策略
主键策略决定数据插入时 ID 如何生成。MyBatis-Plus 支持多种 IdType,常见包括 ASSIGN_ID、AUTO、INPUT、ASSIGN_UUID。官方配置文档说明,全局默认主键策略是 ASSIGN_ID,该策略默认通过 IdentifierGenerator#nextId 使用雪花算法生成 ID,适合 Long、Integer 或 String 类型主键;AUTO 使用数据库自增;INPUT 需要插入前手动设置主键;ASSIGN_UUID 适合 String 主键。(MyBatis-Plus)
ASSIGN_ID 雪花 ID
ASSIGN_ID 是 MyBatis-Plus 默认推荐的分布式 ID 策略。它默认使用雪花算法生成 ID,适合分布式系统、微服务系统、多实例部署、未来可能分库分表的系统。官方文档说明,ASSIGN_ID 适用于 Long、Integer 或 String 类型主键,默认通过 IdentifierGenerator 的 nextId 方法生成。(MyBatis-Plus)
全局配置:
mybatis-plus:
global-config:
db-config:
# 默认雪花 ID
id-type: ASSIGN_ID2
3
4
5
实体写法:
/**
* 用户 ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;2
3
4
5
数据库字段:
id BIGINT NOT NULL COMMENT '主键ID'使用 ASSIGN_ID 时,不要给数据库字段设置 AUTO_INCREMENT:
-- 推荐
id BIGINT NOT NULL COMMENT '主键ID'
-- 不推荐
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID'2
3
4
5
ASSIGN_ID 适用场景:
| 场景 | 是否推荐 |
|---|---|
| 微服务系统 | 推荐 |
| 多实例部署 | 推荐 |
| 分布式任务写入 | 推荐 |
| 未来可能分库分表 | 推荐 |
| 单机小项目 | 可用 |
| 强依赖连续 ID | 不适合 |
| 前端直接处理 Long | 注意精度问题 |
前端如果使用 JavaScript,Long 类型雪花 ID 可能超过安全整数范围。接口返回时可以将 Long ID 序列化为字符串,避免精度丢失。
AUTO 数据库自增
AUTO 使用数据库自增主键,适合单库单表、传统单体项目、数据规模较小且不考虑分布式写入的场景。官方文档说明,IdType.AUTO 表示使用数据库自增 ID 作为主键。(MyBatis-Plus)
数据库字段:
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID'实体字段:
/**
* 用户 ID,数据库自增
*/
@TableId(type = IdType.AUTO)
private Long id;2
3
4
5
建表示例:
CREATE TABLE sys_user (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
username VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';2
3
4
5
6
AUTO 适用场景:
| 场景 | 是否推荐 |
|---|---|
| 单体后台管理系统 | 可用 |
| 单库单表 | 可用 |
| 字典小表 | 可用 |
| 微服务多实例 | 谨慎 |
| 分库分表 | 不推荐 |
| 离线提前生成 ID | 不支持 |
| 数据合并迁移 | 需要处理冲突 |
数据库自增 ID 简单直观,但在分布式系统中容易受数据库实例、分库分表、数据迁移和批量导入影响。
INPUT 手动输入
INPUT 表示插入数据前由业务代码手动设置主键。官方文档说明,IdType.INPUT 需要用户在插入前自行设置主键值。(MyBatis-Plus)
实体字段:
/**
* 外部系统用户 ID
*/
@TableId(type = IdType.INPUT)
private String id;2
3
4
5
使用示例:
UserEntity entity = new UserEntity();
entity.setId("EXT_USER_10001");
entity.setUsername("external_user");
userMapper.insert(entity);2
3
4
适用场景:
| 场景 | 是否适合 |
|---|---|
| 外部系统 ID | 适合 |
| 编码类主键 | 可用 |
| 数据同步 | 适合 |
| 手动维护字典编码 | 可用 |
| 普通业务表 | 不推荐 |
| 用户输入主键 | 谨慎 |
INPUT 的风险是业务代码必须保证主键唯一且非空。普通业务表不建议让前端传主键。
UUID 策略
MyBatis-Plus 新版本中推荐使用 ASSIGN_UUID,不建议使用旧的 UUID、ID_WORKER、ID_WORKER_STR 等已废弃策略。官方注解文档明确提示,应避免使用已废弃的 ID 类型,使用 ASSIGN_ID 或 ASSIGN_UUID。(MyBatis-Plus)
实体字段:
/**
* 字符串 UUID 主键
*/
@TableId(type = IdType.ASSIGN_UUID)
private String id;2
3
4
5
数据库字段:
id VARCHAR(32) NOT NULL COMMENT '主键ID'ASSIGN_UUID 适用场景:
| 场景 | 是否推荐 |
|---|---|
| 外部暴露不可猜测 ID | 可用 |
| 文件记录 ID | 可用 |
| 非高频写入表 | 可用 |
| 核心高频业务表 | 谨慎 |
| 大量 JOIN 表 | 不推荐 |
| 索引敏感场景 | 不推荐 |
UUID 主键的问题是索引体积较大、可读性较差、对数据库聚簇索引和页分裂不友好。普通关系型业务表优先使用 BIGINT + ASSIGN_ID。
分布式 ID 设计
分布式 ID 设计要解决多实例、多服务、多库、多表下 ID 唯一的问题。常见方案包括雪花 ID、数据库号段、Redis 自增、UUID、自定义业务编码等。
常见方案对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 雪花 ID | 本地生成、性能高、趋势递增 | 依赖时钟,前端 Long 精度需处理 | 微服务、分库分表预留 |
| 数据库自增 | 简单稳定 | 分布式扩展差 | 单库单体 |
| 数据库号段 | 性能较好,集中分配 | 需要号段服务 | 大规模业务 |
| Redis 自增 | 简单,集中生成 | 依赖 Redis 可用性 | 中小规模分布式 |
| UUID | 全局唯一,无中心依赖 | 索引大,不利于排序 | 非高频、外部 ID |
| 业务编码 | 可读性强 | 生成规则复杂,容易冲突 | 订单号、单据号 |
普通 Spring Boot 3 + MyBatis-Plus 项目建议:
| 项目类型 | 推荐主键 |
|---|---|
| 单体后台管理系统 | AUTO 或 ASSIGN_ID |
| 微服务项目 | ASSIGN_ID |
| SaaS 多租户系统 | ASSIGN_ID |
| 分库分表预留项目 | ASSIGN_ID |
| 外部同步表 | INPUT 或业务唯一键 |
| 文件附件表 | ASSIGN_ID 或 ASSIGN_UUID |
| 订单号 | 不建议直接用主键,应单独生成业务单号 |
主键 ID 和业务单号要分开。主键用于数据库关联和系统内部唯一标识;业务单号用于用户展示、对账、外部系统交互和检索。
订单表示例:
CREATE TABLE biz_order (
id BIGINT NOT NULL COMMENT '主键ID',
order_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '订单号',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
pay_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '支付金额',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
KEY idx_user_time (user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';2
3
4
5
6
7
8
9
10
主键类型选择
主键类型选择要考虑数据库索引、Java 类型、前端精度、分布式能力、存储空间和后续扩展。
推荐规则:
| 主键类型 | 推荐程度 | 说明 |
|---|---|---|
BIGINT + Long | 推荐 | 通用性最好 |
BIGINT + String 返回 | 推荐 | 后端 Long,前端字符串 |
VARCHAR(32) UUID | 可用 | 适合非高频表 |
INT | 谨慎 | 容量有限 |
| 业务编码主键 | 谨慎 | 后续变更困难 |
| 复合主键 | 不推荐 | MyBatis-Plus 使用不方便 |
Java 实体推荐:
/**
* 主键 ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;2
3
4
5
前端返回时,如果需要避免 Long 精度问题,可以配置 Jackson 将 Long 序列化为字符串。
文件位置:src/main/java/io/github/atengk/framework/jackson/JacksonConfig.java
package io.github.atengk.framework.jackson;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilderCustomizer;
/**
* Jackson 配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
public class JacksonConfig {
/**
* 配置 Long 类型序列化为字符串
*
* @return Jackson 自定义配置
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
return builder -> {
SimpleModule simpleModule = new SimpleModule();
simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance);
builder.modules(simpleModule);
};
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
主键类型建议:
| 场景 | 推荐 |
|---|---|
| 普通业务表 | BIGINT |
| 关联表 | BIGINT |
| 日志表 | BIGINT 或按时间分区设计 |
| 字典表 | BIGINT 或稳定编码字段 |
| 外部系统映射表 | 内部 BIGINT 主键 + 外部 ID 字段 |
| 文件表 | BIGINT 或 VARCHAR(32) |
| 树形表 | BIGINT 主键 + parent_id BIGINT |
ID 回填处理
ID 回填指插入数据后,实体对象中的 id 字段被自动填入生成的主键值。MyBatis-Plus 的 save、insert 在使用 ASSIGN_ID 或 AUTO 时,都可以在插入后让实体对象拿到 ID。
ASSIGN_ID 示例:
UserEntity entity = new UserEntity();
entity.setUsername("admin");
entity.setNickname("管理员");
userService.save(entity);
Long userId = entity.getId();
log.info("新增用户成功,用户ID:{}", userId);2
3
4
5
6
7
8
AUTO 示例:
UserEntity entity = new UserEntity();
entity.setUsername("admin");
entity.setNickname("管理员");
userMapper.insert(entity);
Long userId = entity.getId();
log.info("数据库自增ID回填成功,用户ID:{}", userId);2
3
4
5
6
7
8
批量新增 ID 回填:
List<UserEntity> users = dtoList.stream()
.map(userConvert::toEntity)
.toList();
userService.saveBatch(users, 1000);
List<Long> ids = users.stream()
.map(UserEntity::getId)
.toList();2
3
4
5
6
7
8
9
ID 回填注意事项:
| 场景 | 注意点 |
|---|---|
ASSIGN_ID | 插入前通常已生成 ID |
AUTO | 插入后由数据库回填 |
| 批量插入 | 不同数据库和驱动下回填行为需测试 |
| XML 自定义批量插入 | ID 回填需要单独配置或提前生成 |
INPUT | 必须插入前手动设置 |
| UUID | 插入前生成字符串 ID |
如果业务后续需要用主键插入关联表,建议使用 ASSIGN_ID,因为可以在插入前或插入后稳定获取 ID,适合多表保存流程。
多表保存示例:
@Transactional(rollbackFor = Exception.class)
public Long addUserWithRoles(UserAddDTO dto, List<Long> roleIds) {
UserEntity user = userConvert.toEntity(dto);
this.save(user);
List<UserRoleEntity> userRoles = roleIds.stream()
.map(roleId -> {
UserRoleEntity relation = new UserRoleEntity();
relation.setUserId(user.getId());
relation.setRoleId(roleId);
return relation;
})
.toList();
userRoleService.saveBatch(userRoles, 1000);
log.info("新增用户并绑定角色成功,用户ID:{},角色数量:{}", user.getId(), roleIds.size());
return user.getId();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
主键策略踩坑点
主键策略常见问题集中在数据库字段、实体注解、全局配置、前端精度、ID 回填和历史迁移上。
常见踩坑点如下:
| 问题 | 原因 | 解决方式 |
|---|---|---|
使用 ASSIGN_ID 但数据库设置了自增 | 策略冲突 | 去掉 AUTO_INCREMENT 或改用 IdType.AUTO |
| 插入后 ID 为空 | 未配置主键策略或字段未识别 | 添加 @TableId,检查字段名和类型 |
| 前端收到 ID 精度丢失 | JavaScript 安全整数限制 | Long 序列化为字符串 |
| 自增 ID 在多库中冲突 | 多库都从 1 自增 | 分布式系统改用雪花 ID 或号段 |
| UUID 作为主键性能差 | 字符串索引大且随机 | 高频表使用 BIGINT |
| 业务编码当主键难修改 | 编码规则变化影响关联 | 内部主键和业务编码分离 |
| 批量插入 ID 未回填 | 自定义 XML 或驱动限制 | 插入前生成 ID 或单独测试批量回填 |
INPUT 忘记赋值 | 插入前未设置 ID | Service 层强制校验 |
| 混用多种策略 | 表规范不统一 | 项目级统一默认策略,特殊表局部覆盖 |
| 使用废弃策略 | 老版本迁移遗留 | 改用 ASSIGN_ID 或 ASSIGN_UUID |
主键策略推荐总结:
mybatis-plus:
global-config:
db-config:
id-type: ASSIGN_ID2
3
4
普通实体:
@TableId
private Long id;2
特殊自增表:
@TableId(type = IdType.AUTO)
private Long id;2
外部输入主键表:
@TableId(type = IdType.INPUT)
private String id;2
字符串 UUID 表:
@TableId(type = IdType.ASSIGN_UUID)
private String id;2
主键策略的核心原则是:普通业务表使用 BIGINT + ASSIGN_ID,单库小项目可使用 AUTO,外部同步表使用 INPUT,非高频字符串主键场景才考虑 ASSIGN_UUID。不要把业务单号、用户名、手机号这类可变或可展示字段直接作为主键。
事务管理
事务管理用于保证一组数据库操作的原子性、一致性、隔离性和持久性。在 Spring Boot 3 + MyBatis-Plus 项目中,事务通常由 Spring 的 @Transactional 统一管理,事务边界建议放在 Service 层,而不是 Controller 层或 Mapper 层。对于新增、修改、删除、批量操作、多表操作、状态流转、导入导出入库等写操作,应明确事务范围和回滚规则。
Transactional 使用规范
@Transactional 用于声明方法需要事务管理。普通业务项目中,推荐将事务注解加在 Service 层的 public 方法上。Controller 只负责 HTTP 接入,Mapper 只负责数据库访问,事务边界由 Service 统一控制。
推荐写法:
@Transactional(rollbackFor = Exception.class)
public Long addUser(UserAddDTO dto) {
this.checkUsernameUnique(dto.getUsername(), null);
UserEntity entity = userConvert.toEntity(dto);
this.save(entity);
log.info("新增用户成功,用户ID:{},用户名:{}", entity.getId(), entity.getUsername());
return entity.getId();
}2
3
4
5
6
7
8
9
10
事务使用建议:
| 场景 | 是否建议加事务 |
|---|---|
| 单条查询 | 不需要 |
| 多条查询 | 通常不需要 |
| 单表单条新增 | 可加可不加,建议核心写操作统一加 |
| 单表修改 | 建议加 |
| 单表删除 | 建议加 |
| 多表写入 | 必须加 |
| 批量新增 | 必须加 |
| 批量修改 | 必须加 |
| 状态流转 | 建议加 |
| 导入数据 | 必须设计事务边界 |
| 调用外部接口 | 不建议放在长事务内 |
推荐统一写法:
@Transactional(rollbackFor = Exception.class)不推荐只写:
@Transactional原因是 Spring 默认只对运行时异常和 Error 回滚。如果业务中抛出受检异常,默认事务可能不会回滚。统一配置 rollbackFor = Exception.class 更符合企业业务系统预期。
事务传播行为
事务传播行为用于定义当前方法被另一个事务方法调用时,应该加入已有事务、创建新事务、挂起事务,还是不使用事务。Spring 中最常用的是 Propagation.REQUIRED,也是 @Transactional 默认传播行为。
常见传播行为如下:
| 传播行为 | 含义 | 常见场景 |
|---|---|---|
REQUIRED | 有事务就加入,没有就新建 | 默认推荐,大多数业务写操作 |
REQUIRES_NEW | 总是新建事务,挂起外层事务 | 操作日志、审计记录、独立流水 |
NESTED | 嵌套事务,依赖保存点 | 部分回滚场景,需数据库支持 |
SUPPORTS | 有事务就加入,没有就非事务执行 | 查询方法 |
NOT_SUPPORTED | 挂起当前事务,非事务执行 | 不希望参与事务的耗时操作 |
MANDATORY | 必须在已有事务中执行 | 强制由外层控制事务 |
NEVER | 必须非事务执行 | 极少使用 |
默认写法等价于:
@Transactional(
propagation = Propagation.REQUIRED,
rollbackFor = Exception.class
)2
3
4
操作日志独立事务示例:
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void saveOperationLog(OperationLogEntity logEntity) {
this.save(logEntity);
log.info("保存操作日志成功,业务类型:{},业务ID:{}", logEntity.getBizType(), logEntity.getBizId());
}2
3
4
5
REQUIRES_NEW 适合审计日志、消息发送记录、失败记录等需要独立提交的场景。即使外层业务事务回滚,独立事务中的日志也可以提交。
传播行为使用建议:
| 场景 | 推荐传播行为 |
|---|---|
| 普通业务写操作 | REQUIRED |
| 多表保存 | REQUIRED |
| 批量导入主流程 | REQUIRED 或分批事务 |
| 操作日志 | REQUIRES_NEW |
| 审计流水 | REQUIRES_NEW |
| 局部失败允许继续 | NESTED 或拆分事务 |
| 查询方法 | 不加事务或 SUPPORTS |
| 外部接口调用 | 不建议放事务中 |
事务隔离级别
事务隔离级别用于控制并发事务之间的可见性。隔离级别越高,并发问题越少,但性能和锁竞争成本通常越高。普通业务项目通常使用数据库默认隔离级别即可,MySQL InnoDB 默认通常为 REPEATABLE READ。
Spring 常见隔离级别如下:
| 隔离级别 | 含义 | 使用建议 |
|---|---|---|
DEFAULT | 使用数据库默认隔离级别 | 推荐默认使用 |
READ_UNCOMMITTED | 可读取未提交数据 | 不推荐 |
READ_COMMITTED | 只能读取已提交数据 | Oracle、PostgreSQL 常见 |
REPEATABLE_READ | 同一事务内重复读一致 | MySQL InnoDB 常见 |
SERIALIZABLE | 串行化执行 | 极少使用,性能成本高 |
示例:
@Transactional(
isolation = Isolation.READ_COMMITTED,
rollbackFor = Exception.class
)
public void updateOrderStatus(Long orderId) {
// 订单状态流转逻辑
}2
3
4
5
6
7
隔离级别选择建议:
| 场景 | 建议 |
|---|---|
| 普通后台管理系统 | 使用 DEFAULT |
| MySQL 项目 | 通常保持数据库默认 |
| 高并发库存扣减 | 不只依赖隔离级别,应使用条件更新、锁或队列 |
| 财务账户变更 | 结合事务、流水、锁和幂等设计 |
| 报表查询 | 不建议提高隔离级别解决统计问题 |
| 防止并发覆盖 | 使用乐观锁或条件更新 |
不要随意把隔离级别设置成 SERIALIZABLE。这可能显著降低并发能力,并引发锁等待或死锁问题。
回滚规则
Spring 事务默认在运行时异常和 Error 发生时回滚,受检异常默认不回滚。因此项目中建议统一写 rollbackFor = Exception.class,并通过业务异常表达可预期失败。
业务异常示例:
文件位置:src/main/java/io/github/atengk/common/exception/BusinessException.java
package io.github.atengk.common.exception;
import io.github.atengk.common.result.ResultCode;
import lombok.Getter;
/**
* 业务异常
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
public class BusinessException extends RuntimeException {
private final Integer code;
public BusinessException(String message) {
super(message);
this.code = ResultCode.BUSINESS_ERROR.getCode();
}
public BusinessException(ResultCode resultCode, String message) {
super(message);
this.code = resultCode.getCode();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
事务回滚示例:
@Transactional(rollbackFor = Exception.class)
public Long addUserWithRoles(UserAddDTO dto, List<Long> roleIds) {
this.checkUsernameUnique(dto.getUsername(), null);
UserEntity user = userConvert.toEntity(dto);
this.save(user);
if (CollUtil.isEmpty(roleIds)) {
throw new BusinessException("用户角色不能为空");
}
List<UserRoleEntity> relations = roleIds.stream()
.map(roleId -> {
UserRoleEntity relation = new UserRoleEntity();
relation.setUserId(user.getId());
relation.setRoleId(roleId);
return relation;
})
.toList();
userRoleService.saveBatch(relations, 1000);
log.info("新增用户并绑定角色成功,用户ID:{},角色数量:{}", user.getId(), roleIds.size());
return user.getId();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
回滚规则注意事项:
| 场景 | 是否回滚 |
|---|---|
抛出 RuntimeException | 默认回滚 |
抛出 Error | 默认回滚 |
| 抛出受检异常 | 默认不回滚,除非配置 rollbackFor |
| 异常被捕获且不抛出 | 不回滚 |
| 返回失败对象 | 不回滚 |
设置 rollbackFor = Exception.class | 受检异常也回滚 |
设置 noRollbackFor | 指定异常不回滚 |
错误示例:
@Transactional(rollbackFor = Exception.class)
public void importUser(List<UserAddDTO> dtoList) {
try {
// 入库逻辑
} catch (Exception exception) {
log.error("导入用户失败", exception);
// 异常被吞掉,事务不会回滚
}
}2
3
4
5
6
7
8
9
正确示例:
@Transactional(rollbackFor = Exception.class)
public void importUser(List<UserAddDTO> dtoList) {
try {
// 入库逻辑
} catch (Exception exception) {
log.error("导入用户失败", exception);
throw exception;
}
}2
3
4
5
6
7
8
9
如果必须捕获异常并转换提示,应抛出业务异常:
@Transactional(rollbackFor = Exception.class)
public void importUser(List<UserAddDTO> dtoList) {
try {
// 入库逻辑
} catch (Exception exception) {
log.error("导入用户失败", exception);
throw new BusinessException("导入用户失败,请检查数据格式");
}
}2
3
4
5
6
7
8
9
批量操作事务
批量操作事务需要重点关注数据量、事务大小、锁持有时间、失败策略和错误明细。小批量数据可以一个事务处理;大批量导入不建议一个事务覆盖全部数据,否则容易造成长事务、锁等待、回滚成本高和数据库压力过大。
小批量新增示例:
@Transactional(rollbackFor = Exception.class)
public void batchAddUser(List<UserAddDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return;
}
List<UserEntity> users = dtoList.stream()
.map(userConvert::toEntity)
.toList();
this.saveBatch(users, 1000);
log.info("批量新增用户成功,数量:{}", users.size());
}2
3
4
5
6
7
8
9
10
11
12
13
大批量分批事务可以使用独立方法处理每一批。注意:要让事务生效,分批方法应由 Spring 代理对象调用,不要使用 this.batchSave(...)。
分批事务服务示例:
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserImportTransactionService.java
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 用户导入事务服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class UserImportTransactionService extends ServiceImpl<UserMapper, UserEntity> {
/**
* 独立事务保存一批用户
*
* @param users 用户列表
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void saveUserBatchInNewTransaction(List<UserEntity> users) {
if (CollUtil.isEmpty(users)) {
return;
}
this.saveBatch(users, 1000);
log.info("导入用户批次保存成功,数量:{}", users.size());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
调用方示例:
public void importUsers(List<UserAddDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return;
}
List<UserEntity> users = dtoList.stream()
.map(userConvert::toEntity)
.toList();
List<List<UserEntity>> partitions = CollUtil.split(users, 1000);
for (List<UserEntity> partition : partitions) {
userImportTransactionService.saveUserBatchInNewTransaction(partition);
}
log.info("用户导入完成,总数量:{},批次数:{}", users.size(), partitions.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
批量事务设计建议:
| 场景 | 建议 |
|---|---|
| 几十条到几百条 | 一个事务可以接受 |
| 几千条 | 分批处理 |
| 几万条以上 | 分批事务、异步任务、失败明细 |
| 导入全部成功才提交 | 一个事务,但要限制数据量 |
| 允许部分成功 | 每批独立事务 |
| 批量更新 | 注意锁范围和执行时间 |
| 批量删除 | 尽量分批,避免大事务 |
批量操作不要盲目追求一个事务覆盖全部数据。对于导入场景,用户通常更关心失败明细和可重试能力,而不是所有数据必须同时提交。
多表操作事务
多表操作是事务最典型的使用场景。例如新增用户并绑定角色、新增订单并保存订单明细、支付成功后更新订单和支付流水等。只要多个表的数据必须保持一致,就应该放在同一个事务中。
新增用户并绑定角色示例:
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserRoleBusinessService.java
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.module.system.user.convert.UserConvert;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.entity.UserRoleEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import io.github.atengk.module.system.user.service.UserRoleService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 用户角色业务服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserRoleBusinessService extends ServiceImpl<UserMapper, UserEntity> {
private final UserConvert userConvert;
private final UserRoleService userRoleService;
/**
* 新增用户并绑定角色
*
* @param dto 用户新增参数
* @param roleIds 角色ID列表
* @return 用户ID
*/
@Transactional(rollbackFor = Exception.class)
public Long addUserWithRoles(UserAddDTO dto, List<Long> roleIds) {
if (CollUtil.isEmpty(roleIds)) {
throw new BusinessException("角色不能为空");
}
UserEntity user = userConvert.toEntity(dto);
this.save(user);
List<UserRoleEntity> relations = roleIds.stream()
.map(roleId -> {
UserRoleEntity relation = new UserRoleEntity();
relation.setUserId(user.getId());
relation.setRoleId(roleId);
return relation;
})
.toList();
userRoleService.saveBatch(relations, 1000);
log.info("新增用户并绑定角色成功,用户ID:{},角色数量:{}", user.getId(), roleIds.size());
return user.getId();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
多表事务建议:
| 场景 | 建议 |
|---|---|
| 主表 + 子表新增 | 同一事务 |
| 主表修改 + 关联表重建 | 同一事务 |
| 支付成功更新订单 + 流水 | 同一事务 |
| 库存扣减 + 库存流水 | 同一事务 |
| 用户删除 + 关系解绑 | 同一事务 |
| 数据库写入 + 发 MQ | 不直接放同一事务,考虑事务消息或消息表 |
| 数据库写入 + 外部 HTTP | 不建议长事务包住外部调用 |
多表事务中,外部系统调用不要放在数据库事务中长时间执行。应优先先完成本地事务,再通过消息、事件或补偿机制处理外部系统。
嵌套事务
嵌套事务用于外层事务中包含一个可局部回滚的内层事务。Spring 中可以使用 Propagation.NESTED 表示嵌套事务,它依赖数据库保存点机制。并非所有事务管理器和数据库场景都适合使用嵌套事务。
示例:主流程中保存用户,内层保存扩展信息失败时只回滚扩展信息。
@Transactional(rollbackFor = Exception.class)
public void addUserWithOptionalExtra(UserAddDTO dto) {
UserEntity user = userConvert.toEntity(dto);
this.save(user);
try {
userExtraService.saveExtraInNestedTransaction(user.getId(), dto.getRemark());
} catch (Exception exception) {
log.warn("保存用户扩展信息失败,用户ID:{},错误信息:{}", user.getId(), exception.getMessage());
}
log.info("新增用户主流程完成,用户ID:{}", user.getId());
}2
3
4
5
6
7
8
9
10
11
12
13
嵌套方法:
@Transactional(propagation = Propagation.NESTED, rollbackFor = Exception.class)
public void saveExtraInNestedTransaction(Long userId, String remark) {
UserExtraEntity extra = new UserExtraEntity();
extra.setUserId(userId);
extra.setRemark(remark);
this.save(extra);
log.info("保存用户扩展信息成功,用户ID:{}", userId);
}2
3
4
5
6
7
8
9
嵌套事务使用建议:
| 场景 | 是否建议 |
|---|---|
| 局部失败不影响主流程 | 可考虑 |
| 普通业务写操作 | 不需要 |
| 操作日志独立提交 | 更推荐 REQUIRES_NEW |
| 批量导入部分失败 | 可考虑分批独立事务 |
| 数据库不支持保存点 | 不适合 |
| 团队不熟悉事务传播 | 谨慎使用 |
嵌套事务可读性和排查难度较高。大多数业务可以通过拆分事务、独立事务或明确补偿机制解决,不必强行使用 NESTED。
事务失效场景
事务失效是 Spring 项目中最常见的问题之一。事务依赖 Spring AOP 代理,如果方法调用没有经过代理,或者异常没有抛出,事务就可能不生效。
常见失效场景如下:
| 场景 | 原因 | 解决方式 |
|---|---|---|
| 同类内部方法调用 | this.method() 不经过代理 | 拆到另一个 Service 或通过代理调用 |
方法不是 public | 代理无法按预期拦截 | 事务方法使用 public |
| 异常被捕获未抛出 | 事务感知不到异常 | 捕获后重新抛出 |
| 抛出受检异常 | 默认不回滚 | 配置 rollbackFor = Exception.class |
| 类未交给 Spring 管理 | 没有代理对象 | 使用 @Service 等注解 |
方法是 final | 代理受限 | 避免事务方法 final |
| 多线程执行 | 新线程不继承事务 | 在线程内重新开启事务 |
| 数据源未被事务管理 | 事务管理器不匹配 | 检查数据源和事务管理器 |
| 私有方法加事务 | 不会被代理 | 不要在私有方法上加事务 |
错误示例:
public void importUsers(List<UserAddDTO> dtoList) {
List<List<UserAddDTO>> partitions = CollUtil.split(dtoList, 1000);
for (List<UserAddDTO> partition : partitions) {
this.savePartition(partition);
}
}
@Transactional(rollbackFor = Exception.class)
public void savePartition(List<UserAddDTO> partition) {
// this 内部调用,事务可能不生效
}2
3
4
5
6
7
8
9
10
11
推荐拆分到另一个 Service:
@Service
@RequiredArgsConstructor
public class UserImportService {
private final UserImportTransactionService userImportTransactionService;
public void importUsers(List<UserEntity> users) {
List<List<UserEntity>> partitions = CollUtil.split(users, 1000);
for (List<UserEntity> partition : partitions) {
userImportTransactionService.saveUserBatchInNewTransaction(partition);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
事务失效排查建议:
| 排查项 | 检查方式 |
|---|---|
| 是否经过 Spring Bean 调用 | 不要 new 对象 |
| 是否同类内部调用 | 检查 this.xxx() |
| 注解是否在 public 方法上 | 检查方法修饰符 |
| 异常是否抛出 | 检查 catch 块 |
| 回滚规则是否正确 | 检查 rollbackFor |
| 数据源是否正确 | 多数据源检查事务管理器 |
| 日志是否显示事务开启 | 可打开 Spring 事务 debug 日志 |
异步方法中的事务
异步方法中的事务需要单独设计。@Async 会让方法在新的线程中执行,新线程不会继承调用线程的事务上下文,也不会继承 ThreadLocal 中的用户上下文、租户上下文,除非显式传递。
错误理解:
外层方法开启了事务,异步方法也在同一个事务里。实际情况:
异步方法在新线程执行,事务上下文不自动传递。异步保存日志示例:
@Async
@Transactional(rollbackFor = Exception.class)
public void saveImportLogAsync(ImportLogEntity logEntity) {
this.save(logEntity);
log.info("异步保存导入日志成功,日志ID:{}", logEntity.getId());
}2
3
4
5
6
异步事务注意事项:
| 问题 | 建议 |
|---|---|
| 事务上下文 | 新线程重新开启事务 |
| 用户上下文 | 显式传递用户 ID、租户 ID |
| 异常处理 | 异步异常不会直接抛给调用方 |
| 数据一致性 | 不要依赖外层事务未提交的数据 |
| 外层回滚 | 异步事务可能已经提交 |
| 线程池 | 必须使用受控线程池 |
异步方法不要读取外层事务尚未提交的数据。如果必须在事务提交后执行异步逻辑,可以使用事务事件。
事务提交后发布事件示例:
@Transactional(rollbackFor = Exception.class)
public Long addUser(UserAddDTO dto) {
UserEntity user = userConvert.toEntity(dto);
this.save(user);
applicationEventPublisher.publishEvent(new UserCreatedEvent(user.getId()));
log.info("新增用户成功,用户ID:{}", user.getId());
return user.getId();
}2
3
4
5
6
7
8
9
10
事务提交后监听:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleUserCreated(UserCreatedEvent event) {
log.info("事务提交后处理用户创建事件,用户ID:{}", event.getUserId());
}2
3
4
异步加事务事件:
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleUserCreatedAsync(UserCreatedEvent event) {
log.info("事务提交后异步处理用户创建事件,用户ID:{}", event.getUserId());
}2
3
4
5
这种方式适合事务提交后发送消息、写搜索索引、发送通知、刷新缓存等场景。
多数据源事务
多数据源事务是指一个业务方法中同时操作多个数据源。普通 @Transactional 默认只管理一个事务管理器。多数据源场景需要明确当前方法使用哪个事务管理器,或者引入分布式事务方案。
单数据源默认写法:
@Transactional(rollbackFor = Exception.class)
public void updateUser() {
// 默认事务管理器
}2
3
4
指定事务管理器:
@Transactional(transactionManager = "masterTransactionManager", rollbackFor = Exception.class)
public void updateMasterData() {
// 主库事务
}2
3
4
多数据源事务常见场景:
| 场景 | 建议 |
|---|---|
| 读写分离 | 写操作走主库事务,读操作不强制事务 |
| 多业务库 | 尽量避免一个本地事务跨多个库 |
| 主库 + 日志库 | 日志可独立事务或异步写入 |
| 订单库 + 库存库 | 考虑分布式事务、最终一致性或消息驱动 |
| 报表库 | 不建议和业务写事务绑定 |
| 数据同步 | 使用异步同步或消息补偿 |
多数据源本地事务不能天然保证多个数据库同时提交或同时回滚。例如主库写成功,日志库写失败,默认事务管理器可能只回滚其中一个数据源。因此跨库强一致需要专门设计。
多数据源事务建议:
| 需求 | 推荐方案 |
|---|---|
| 强一致 | 分布式事务 |
| 最终一致 | MQ、事务消息、补偿任务 |
| 审计日志 | 独立事务或异步写入 |
| 报表同步 | 异步同步 |
| 跨库查询 | 尽量通过服务接口或数据同步解决 |
| 跨库写入 | 评估一致性要求后设计 |
分布式事务扩展
分布式事务用于解决跨服务、跨数据库、跨消息系统的一致性问题。普通 Spring 本地事务只能保证一个数据源内的一致性,不能天然保证多个服务或多个数据库的一致提交。
常见分布式一致性方案如下:
| 方案 | 特点 | 适用场景 |
|---|---|---|
| 2PC / XA | 强一致,性能和可用性成本高 | 金融、强一致核心链路 |
| TCC | Try、Confirm、Cancel 三阶段 | 账户、库存、交易类强一致 |
| Saga | 长事务拆分为多个本地事务和补偿 | 业务流程长、可补偿场景 |
| 本地消息表 | 本地事务写业务表和消息表,再异步投递 | 常用最终一致方案 |
| 事务消息 | MQ 提供事务消息能力 | 订单创建后发送消息 |
| Outbox Pattern | 业务库保存事件,由投递器发送 | 微服务事件驱动 |
| 补偿任务 | 定时扫描异常数据修复 | 兜底一致性 |
本地消息表示例:
CREATE TABLE sys_message_outbox (
id BIGINT NOT NULL COMMENT '主键ID',
biz_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '业务类型',
biz_id BIGINT NOT NULL DEFAULT 0 COMMENT '业务ID',
topic VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消息主题',
message_body TEXT NOT NULL COMMENT '消息内容',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待发送,1已发送,2发送失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
KEY idx_status_time (status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地消息表';2
3
4
5
6
7
8
9
10
11
12
13
业务事务中写业务表和消息表:
@Transactional(rollbackFor = Exception.class)
public Long createOrder(OrderCreateDTO dto) {
OrderEntity order = orderConvert.toEntity(dto);
orderService.save(order);
MessageOutboxEntity message = new MessageOutboxEntity();
message.setBizType("ORDER_CREATED");
message.setBizId(order.getId());
message.setTopic("order.created");
message.setMessageBody(JSONUtil.toJsonStr(order));
message.setStatus(0);
messageOutboxService.save(message);
log.info("创建订单并写入本地消息成功,订单ID:{},消息ID:{}", order.getId(), message.getId());
return order.getId();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
后续由定时任务或消息投递服务扫描 status = 0 的消息进行投递。这样可以保证“订单创建成功”和“待发送消息记录保存成功”在同一个本地事务中完成,再通过异步投递实现最终一致。
分布式事务选型建议:
| 场景 | 建议 |
|---|---|
| 订单创建后通知库存 | MQ 最终一致 |
| 支付成功后更新订单 | 本地事务 + 幂等回调 |
| 跨服务扣余额和扣库存 | TCC 或可靠消息 |
| 审批流程跨多个系统 | Saga 或事件驱动 |
| 日志、通知、短信 | 异步最终一致 |
| 财务核心记账 | 强一致方案,谨慎设计 |
| 普通后台管理 | 尽量避免分布式事务 |
分布式事务不是优先方案。能通过业务边界拆分、本地事务、幂等、消息重试和补偿任务解决的问题,不要轻易引入复杂分布式事务框架。对于大多数业务系统,推荐优先采用“本地事务 + 消息表或事务消息 + 幂等消费 + 补偿任务”的最终一致方案。
数据权限
数据权限用于控制“用户能看到哪些数据”,常见于后台管理系统、SaaS 多租户系统、组织架构系统、审批系统和业务中台。MyBatis-Plus 提供 DataPermissionInterceptor,它会在 SQL 执行前拦截 SQL,并根据权限规则动态追加权限相关 SQL 条件,从而限制用户只能访问授权范围内的数据。该插件依赖 SQL 解析能力,适合统一处理列表、分页、详情、自定义 SQL 等查询场景。(MyBatis-Plus)
数据权限场景
数据权限不是接口权限。接口权限控制“能不能访问某个接口”,数据权限控制“访问接口后能看到哪些数据”。例如用户 A 和用户 B 都有“查询订单列表”接口权限,但用户 A 只能看自己部门订单,用户 B 可以看全部订单。
常见数据权限场景如下:
| 场景 | 说明 |
|---|---|
| 本人数据 | 只能查看 create_by = 当前用户ID 的数据 |
| 本部门数据 | 只能查看 dept_id = 当前部门ID 的数据 |
| 本部门及下级部门数据 | 可以查看当前部门和下级部门数据 |
| 指定部门数据 | 角色绑定了若干部门,只能查看这些部门数据 |
| 全部数据 | 管理员或特定角色可以查看全部 |
| 租户内数据 | SaaS 场景下只能查看当前租户数据 |
| 自定义数据范围 | 按业务规则、项目、区域、客户、门店等维度控制 |
建议把数据权限做成统一能力,而不是在每个 Mapper XML 中手写权限条件。统一插件方案更容易保证一致性,也更利于排查越权问题。
用户维度数据权限
用户维度数据权限通常基于 create_by、owner_id、user_id 等字段控制。例如“只能查看自己创建的数据”“只能查看自己负责的客户”“只能查看自己提交的审批单”。
数据库字段示例:
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID'查询条件示例:
AND create_by = 当前用户ID适用场景:
| 场景 | 字段 |
|---|---|
| 我创建的数据 | create_by |
| 我负责的客户 | owner_id |
| 我的订单 | user_id |
| 我的审批单 | apply_user_id |
| 我的任务 | assignee_id |
用户维度权限适合个人工作台、我的数据、业务负责人、销售归属等场景。它的特点是规则简单,但数据共享能力较弱。
部门维度数据权限
部门维度数据权限通常基于 dept_id 控制。例如“只能查看本部门数据”“可以查看本部门及下级部门数据”。这是企业后台系统最常见的数据权限模式。
数据库字段示例:
dept_id BIGINT NOT NULL DEFAULT 0 COMMENT '部门ID'本部门条件:
AND dept_id = 当前部门ID本部门及下级部门条件:
AND dept_id IN (当前部门ID, 下级部门ID列表)部门维度权限建议配合组织架构表使用:
CREATE TABLE sys_dept (
id BIGINT NOT NULL COMMENT '部门ID',
parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父部门ID',
dept_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '部门名称',
ancestors VARCHAR(500) NOT NULL DEFAULT '' COMMENT '祖级列表',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
KEY idx_parent_id (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表';2
3
4
5
6
7
8
9
如果部门层级查询频繁,可以使用 ancestors 字段、闭包表、缓存部门树等方式优化下级部门查询。不要在每次 SQL 权限解析时递归查数据库。
角色维度数据权限
角色维度数据权限通常是“角色决定数据范围”。例如管理员可以查看全部数据,部门负责人可以查看本部门及下级部门数据,普通员工只能查看本人数据。
角色表可以配置数据范围字段:
CREATE TABLE sys_role (
id BIGINT NOT NULL COMMENT '角色ID',
role_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '角色编码',
role_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '角色名称',
data_scope TINYINT NOT NULL DEFAULT 1 COMMENT '数据范围:1全部,2本部门,3本部门及下级,4本人,5自定义',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_role_code (role_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';2
3
4
5
6
7
8
9
自定义部门范围关系表:
CREATE TABLE sys_role_dept (
id BIGINT NOT NULL COMMENT '主键ID',
role_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色ID',
dept_id BIGINT NOT NULL DEFAULT 0 COMMENT '部门ID',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
KEY idx_role_id (role_id),
KEY idx_dept_id (dept_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色部门数据权限表';2
3
4
5
6
7
8
9
角色数据范围枚举如下。
文件位置:src/main/java/io/github/atengk/framework/security/DataScopeTypeEnum.java
package io.github.atengk.framework.security;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 数据权限范围枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum DataScopeTypeEnum {
/**
* 全部数据
*/
ALL(1, "全部数据"),
/**
* 本部门数据
*/
DEPT(2, "本部门数据"),
/**
* 本部门及下级部门数据
*/
DEPT_AND_CHILD(3, "本部门及下级部门数据"),
/**
* 本人数据
*/
SELF(4, "本人数据"),
/**
* 自定义部门数据
*/
CUSTOM(5, "自定义部门数据");
/**
* 数据范围编码
*/
private final Integer code;
/**
* 数据范围名称
*/
private final String name;
/**
* 根据编码获取枚举
*
* @param code 数据范围编码
* @return 数据范围枚举
*/
public static DataScopeTypeEnum ofCode(Integer code) {
if (ObjectUtil.isNull(code)) {
return SELF;
}
return Arrays.stream(values())
.filter(item -> ObjectUtil.equals(item.getCode(), code))
.findFirst()
.orElse(SELF);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
角色维度数据权限通常在登录时计算好,放入当前登录用户上下文中,避免每次 SQL 拦截时频繁查询角色表和部门表。
自定义数据范围
自定义数据范围用于处理部门权限无法表达的场景,例如按区域、项目、客户、门店、仓库、产品线控制数据。自定义数据范围可以通过角色绑定部门,也可以通过业务维度授权表实现。
当前登录用户对象可以保存已经计算好的数据权限信息。
文件位置:src/main/java/io/github/atengk/framework/security/LoginUser.java
package io.github.atengk.framework.security;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
/**
* 当前登录用户
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class LoginUser implements Serializable {
/**
* 用户 ID
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 部门 ID
*/
private Long deptId;
/**
* 租户 ID
*/
private Long tenantId;
/**
* 数据范围
*/
private DataScopeTypeEnum dataScope;
/**
* 可访问部门 ID 列表
*/
private List<Long> dataDeptIds = Collections.emptyList();
/**
* 是否超级管理员
*/
private Boolean superAdmin;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
自定义数据范围建议在用户登录后完成计算,并缓存到登录态、Redis 或安全上下文中。不要在 DataPermissionInterceptor 每次执行时临时查询大量权限数据,否则会明显拖慢所有查询。
DataPermissionInterceptor 配置
DataPermissionInterceptor 是 MyBatis-Plus 的数据权限插件。它基于 MybatisPlusInterceptor 注册为内部拦截器;MyBatis-Plus 插件主体通过 MybatisPlusInterceptor 管理内部插件,并代理 MyBatis 执行流程。多个插件同时使用时,应注意顺序,官方建议会改造 SQL 的插件优先,分页、乐观锁随后,SQL 规范和防全表更新类插件放后面。(MyBatis-Plus)
本节给出一个可落地的简化版本:基于表字段 create_by、dept_id 动态追加数据权限条件。
文件位置:src/main/java/io/github/atengk/framework/security/CurrentUserContext.java
package io.github.atengk.framework.security;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.ObjectUtil;
import org.springframework.stereotype.Component;
/**
* 当前用户上下文
*
* @author Ateng
* @since 2026-05-05
*/
@Component
public class CurrentUserContext {
private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>();
/**
* 设置当前登录用户
*
* @param loginUser 登录用户
*/
public void setLoginUser(LoginUser loginUser) {
USER_HOLDER.set(loginUser);
}
/**
* 获取当前登录用户
*
* @return 登录用户
*/
public LoginUser getLoginUser() {
return USER_HOLDER.get();
}
/**
* 获取当前登录用户,未登录时返回空用户
*
* @return 登录用户
*/
public LoginUser getLoginUserOrDefault() {
LoginUser loginUser = getLoginUser();
if (ObjectUtil.isNotNull(loginUser)) {
return loginUser;
}
LoginUser defaultUser = new LoginUser();
defaultUser.setUserId(0L);
defaultUser.setDeptId(0L);
defaultUser.setDataScope(DataScopeTypeEnum.SELF);
defaultUser.setSuperAdmin(false);
return defaultUser;
}
/**
* 判断当前用户是否超级管理员
*
* @return 是否超级管理员
*/
public boolean isSuperAdmin() {
LoginUser loginUser = getLoginUser();
return ObjectUtil.isNotNull(loginUser) && BooleanUtil.isTrue(loginUser.getSuperAdmin());
}
/**
* 清理当前登录用户
*/
public void clear() {
USER_HOLDER.remove();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
下面这个处理器根据当前用户的数据范围,为不同表追加不同权限条件。这里假设需要进行数据权限控制的业务表都有 create_by 和 dept_id 字段。
文件位置:src/main/java/io/github/atengk/framework/mybatis/DataPermissionHandler.java
package io.github.atengk.framework.mybatis;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.handler.MultiDataPermissionHandler;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.DataScopeTypeEnum;
import io.github.atengk.framework.security.LoginUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Table;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 数据权限处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DataPermissionHandler implements MultiDataPermissionHandler {
private static final Set<String> DATA_PERMISSION_TABLES = Set.of(
"sys_user",
"biz_order",
"biz_customer",
"biz_contract"
);
private static final Set<String> IGNORE_MAPPER_METHODS = Set.of(
"io.github.atengk.module.system.user.mapper.UserMapper.selectUserOptions",
"io.github.atengk.module.system.dept.mapper.DeptMapper.selectDeptTree"
);
private final CurrentUserContext currentUserContext;
/**
* 获取数据权限 SQL 条件
*
* @param table 表对象
* @param where 当前 where 条件
* @param mappedStatementId Mapper 方法 ID
* @return 数据权限 SQL 表达式
*/
@Override
public Expression getSqlSegment(Table table, Expression where, String mappedStatementId) {
String tableName = table.getName();
if (shouldIgnore(tableName, mappedStatementId)) {
return null;
}
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
if (currentUserContext.isSuperAdmin()) {
return null;
}
String alias = getTableAlias(table);
String sqlSegment = buildDataPermissionSql(alias, loginUser);
if (StrUtil.isBlank(sqlSegment)) {
return null;
}
try {
Expression expression = CCJSqlParserUtil.parseCondExpression(sqlSegment);
log.debug("追加数据权限条件,Mapper方法:{},表名:{},条件:{}",
mappedStatementId, tableName, sqlSegment);
return expression;
} catch (Exception exception) {
log.error("解析数据权限SQL条件失败,Mapper方法:{},表名:{},条件:{}",
mappedStatementId, tableName, sqlSegment, exception);
throw new IllegalStateException("解析数据权限条件失败");
}
}
/**
* 判断是否忽略数据权限
*
* @param tableName 表名
* @param mappedStatementId Mapper 方法 ID
* @return 是否忽略
*/
private boolean shouldIgnore(String tableName, String mappedStatementId) {
if (!DATA_PERMISSION_TABLES.contains(tableName)) {
return true;
}
return IGNORE_MAPPER_METHODS.contains(mappedStatementId);
}
/**
* 获取表别名,没有别名时使用表名
*
* @param table 表对象
* @return 表别名或表名
*/
private String getTableAlias(Table table) {
if (table.getAlias() != null && StrUtil.isNotBlank(table.getAlias().getName())) {
return table.getAlias().getName();
}
return table.getName();
}
/**
* 构建数据权限 SQL 片段
*
* @param alias 表别名
* @param loginUser 登录用户
* @return SQL 条件片段
*/
private String buildDataPermissionSql(String alias, LoginUser loginUser) {
DataScopeTypeEnum dataScope = loginUser.getDataScope();
if (DataScopeTypeEnum.ALL.equals(dataScope)) {
return null;
}
if (DataScopeTypeEnum.SELF.equals(dataScope)) {
return StrUtil.format("{}.create_by = {}", alias, loginUser.getUserId());
}
if (DataScopeTypeEnum.DEPT.equals(dataScope)) {
return StrUtil.format("{}.dept_id = {}", alias, loginUser.getDeptId());
}
if (DataScopeTypeEnum.DEPT_AND_CHILD.equals(dataScope) || DataScopeTypeEnum.CUSTOM.equals(dataScope)) {
return buildDeptInSql(alias, loginUser.getDataDeptIds());
}
return StrUtil.format("{}.create_by = {}", alias, loginUser.getUserId());
}
/**
* 构建部门 IN 条件
*
* @param alias 表别名
* @param deptIds 部门 ID 列表
* @return SQL 条件片段
*/
private String buildDeptInSql(String alias, List<Long> deptIds) {
if (CollUtil.isEmpty(deptIds)) {
return "1 = 0";
}
String idText = deptIds.stream()
.map(String::valueOf)
.collect(Collectors.joining(","));
return StrUtil.format("{}.dept_id IN ({})", alias, idText);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
注册 MyBatis-Plus 插件。
文件位置:src/main/java/io/github/atengk/framework/mybatis/MyBatisPlusConfig.java
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 配置
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class MyBatisPlusConfig {
private final DataPermissionHandler dataPermissionHandler;
/**
* 配置 MyBatis-Plus 拦截器
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 数据权限插件:对 SQL 追加用户可访问的数据范围条件
interceptor.addInnerInterceptor(new DataPermissionInterceptor(dataPermissionHandler));
// 乐观锁插件:处理 @Version 字段
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 分页插件:单库项目建议显式指定数据库类型
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
log.info("MyBatis-Plus插件初始化完成,已启用数据权限、乐观锁、分页插件");
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
数据权限插件会改写 SQL,建议放在分页插件前面。这样分页 COUNT SQL 和列表 SQL 都能基于权限后的数据范围执行。
SQL 注入数据权限条件
这里的“SQL 注入数据权限条件”指的是插件向原 SQL 注入权限过滤条件,不是安全漏洞意义上的 SQL 注入。实现时必须注意:权限条件只能来自后端可信上下文,不能直接拼接前端传入的任意字段、表名或 SQL 片段。
例如原 SQL:
SELECT u.id, u.username, u.dept_id
FROM sys_user u
WHERE u.deleted = 02
3
当前用户数据范围为本部门,部门 ID 为 1001,插件追加后类似:
SELECT u.id, u.username, u.dept_id
FROM sys_user u
WHERE u.deleted = 0
AND u.dept_id = 10012
3
4
当前用户数据范围为本人,用户 ID 为 2001,插件追加后类似:
SELECT u.id, u.username, u.dept_id
FROM sys_user u
WHERE u.deleted = 0
AND u.create_by = 20012
3
4
权限条件拼接规范如下:
| 内容 | 是否允许来自前端 |
|---|---|
| 用户 ID | 不允许,应来自登录态 |
| 部门 ID | 不允许,应来自登录态或权限缓存 |
| 可访问部门列表 | 不允许,应由后端计算 |
| 表名 | 不允许前端指定 |
| 字段名 | 不允许前端指定 |
| 排序字段 | 必须白名单映射 |
| SQL 片段 | 禁止前端传入 |
在上面的示例实现中,userId、deptId、dataDeptIds 都来自后端登录上下文,不来自请求参数;表名通过固定白名单 DATA_PERMISSION_TABLES 控制。这是基本安全底线。
数据权限与分页查询
数据权限与分页查询必须同时生效。否则会出现两类问题:第一页数据被权限过滤后数量不对,或者总数统计包含无权限数据。使用 DataPermissionInterceptor 时,应确保分页插件执行在权限条件追加之后,让分页数据和 COUNT 统计都基于权限过滤后的 SQL。
分页查询示例:
public PageResult<UserPageVO> pageUser(UserPageQuery query) {
Page<UserPageVO> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserPageVO> result = userMapper.selectUserPage(page, query);
return PageResult.of(result);
}2
3
4
5
Mapper XML:
<select id="selectUserPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
u.id,
u.username,
u.nickname,
u.dept_id,
u.status,
u.create_time
FROM sys_user u
WHERE u.deleted = 0
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
ORDER BY u.create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
经过数据权限插件后,SQL 会在原有条件上追加部门或本人范围。分页插件随后处理分页和 COUNT。分页插件本身支持自定义 Mapper 方法分页,返回 IPage 时入参分页对象不能为 null;返回 List 时需要手动设置 records。(MyBatis-Plus)
数据权限与分页注意事项:
| 问题 | 建议 |
|---|---|
| 总数不准 | 检查数据权限插件是否在分页前生效 |
| 分页空页 | 检查权限条件是否过窄 |
| COUNT 慢 | 自定义 COUNT SQL 或优化索引 |
| 深分页慢 | 加时间范围、游标分页或限制最大页码 |
| 多表分页 | 权限条件要追加到正确主表 |
| 表别名缺失 | SQL 中建议给主表设置别名 |
数据权限与关联查询
关联查询中,数据权限条件必须作用在正确的表上。通常建议作用在主业务表上,例如用户列表作用在 sys_user u,订单列表作用在 biz_order o,客户列表作用在 biz_customer c。
示例:订单列表关联客户和用户。
<select id="selectOrderPage" resultType="io.github.atengk.module.order.vo.OrderPageVO">
SELECT
o.id,
o.order_no,
o.pay_amount,
o.dept_id,
o.create_by,
c.customer_name,
u.username AS create_user_name
FROM biz_order o
LEFT JOIN biz_customer c ON c.id = o.customer_id AND c.deleted = 0
LEFT JOIN sys_user u ON u.id = o.create_by AND u.deleted = 0
WHERE o.deleted = 0
ORDER BY o.create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
如果 biz_order 在数据权限表白名单中,插件会对 o 追加权限条件,例如:
AND o.dept_id IN (1001, 1002, 1003)关联查询注意事项:
| 场景 | 建议 |
|---|---|
| 主表有权限字段 | 权限条件作用于主表 |
| 关联表也有权限字段 | 谨慎,避免重复过滤导致数据缺失 |
| 多个业务表 JOIN | 明确哪个表代表数据归属 |
| 一对多分页 | 先分页主表,再查子表 |
| 表别名 | 必须规范使用别名 |
| 统计报表 | 权限条件通常作用于事实表 |
| 子查询 | 确认插件是否能正确解析和追加 |
复杂关联查询、UNION、窗口函数、复杂报表 SQL,需要重点做 Mapper 单元测试,确认权限条件追加后 SQL 仍然正确。
数据权限排除规则
不是所有 SQL 都应该加数据权限。例如部门树、字典列表、登录查询、当前用户信息、权限菜单、系统配置等接口可能不需要按业务数据范围过滤。数据权限排除规则应统一维护,不能靠开发人员在 SQL 中临时绕过。
常见排除场景:
| 场景 | 说明 |
|---|---|
| 登录认证 | 登录前没有用户上下文 |
| 当前用户信息 | 查询自己登录信息 |
| 菜单权限 | 由角色权限控制,不按数据范围过滤 |
| 字典数据 | 通常全局可见或按租户控制 |
| 部门树 | 用于选择范围,不一定按数据权限过滤 |
| 超级管理员 | 默认不追加数据权限 |
| 平台级任务 | 定时任务可能需要全量处理 |
| 特定 Mapper 方法 | 例如下拉选项、内部校验查询 |
可以通过 mappedStatementId 做排除,前文 IGNORE_MAPPER_METHODS 就是这种方式:
private static final Set<String> IGNORE_MAPPER_METHODS = Set.of(
"io.github.atengk.module.system.user.mapper.UserMapper.selectUserOptions",
"io.github.atengk.module.system.dept.mapper.DeptMapper.selectDeptTree"
);2
3
4
如果项目更规范,可以自定义注解标记排除方法,并在启动时扫描 Mapper 方法生成排除集合。简单项目使用 mappedStatementId 白名单已经足够。
数据权限排除建议:
| 规范项 | 建议 |
|---|---|
| 排除方式 | 使用白名单,不使用黑名单 |
| 排除范围 | 尽量精确到 Mapper 方法 |
| 超级管理员 | 在 Handler 中直接返回 null |
| 定时任务 | 使用系统上下文或专用排除规则 |
| 导出接口 | 默认应受数据权限控制 |
| 统计报表 | 默认应受数据权限控制 |
| 管理员接口 | 也应明确是否排除,不要默认放开 |
数据权限最终要配合接口权限、租户隔离、字段脱敏和操作日志一起使用。数据权限只解决“行级数据范围”问题,不能替代登录认证、接口授权、租户隔离和敏感字段保护。
多租户
多租户用于在同一套应用中服务多个租户,并保证不同租户之间的数据隔离。MyBatis-Plus 提供 TenantLineInnerInterceptor 实现行级租户隔离,它会在 SQL 执行前自动追加租户条件,使每个租户只能访问自己的数据。该插件的核心配置是 TenantLineHandler,用于提供租户 ID、租户字段名、忽略租户的表,以及插入时是否忽略租户字段。(MyBatis-Plus)
租户字段设计
租户字段建议统一命名为 tenant_id,Java 实体属性命名为 tenantId。所有需要租户隔离的业务表都应包含该字段,并建立必要索引。租户字段通常不允许前端直接传入,应从登录上下文、Token、请求头、网关或域名解析结果中获取。
数据库字段示例:
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID'普通业务表示例:
CREATE TABLE biz_order (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
order_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '订单号',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
dept_id BIGINT NOT NULL DEFAULT 0 COMMENT '部门ID',
pay_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '支付金额',
order_status TINYINT NOT NULL DEFAULT 10 COMMENT '订单状态:10待支付,20已支付,90已取消',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_order_no (tenant_id, order_no),
KEY idx_tenant_status_time (tenant_id, order_status, create_time),
KEY idx_tenant_user_time (tenant_id, user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
租户字段设计建议如下:
| 场景 | 建议 |
|---|---|
| 业务主表 | 必须包含 tenant_id |
| 业务明细表 | 建议包含 tenant_id,即使可通过主表关联获取 |
| 用户表 | SaaS 系统建议包含 tenant_id |
| 角色表 | 租户内角色建议包含 tenant_id |
| 菜单表 | 平台公共菜单通常不包含 tenant_id |
| 字典表 | 全局字典可不包含,租户字典应包含 |
| 日志表 | 建议包含,便于按租户审计和清理 |
| 租户表本身 | 不应被租户插件过滤 |
| 系统配置表 | 平台级配置通常不包含,租户级配置应包含 |
实体父类可以单独设计为 TenantBaseEntity,避免所有实体都强行拥有租户字段。
文件位置:src/main/java/io/github/atengk/common/entity/TenantBaseEntity.java
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Getter;
import lombok.Setter;
/**
* 租户实体公共父类
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class TenantBaseEntity extends BaseEntity {
/**
* 租户 ID
*/
@TableField(fill = FieldFill.INSERT)
private Long tenantId;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
租户字段通常要参与唯一索引。例如订单号在租户内唯一,而不是全平台唯一:
UNIQUE KEY uk_tenant_order_no (tenant_id, order_no)如果业务要求用户名在每个租户内唯一,也应使用联合唯一索引:
UNIQUE KEY uk_tenant_username (tenant_id, username)TenantLineInnerInterceptor 配置
TenantLineInnerInterceptor 是 MyBatis-Plus 的多租户插件。它通过 TenantLineHandler 获取当前租户 ID、租户字段名,并判断哪些表需要忽略租户条件。官方接口中 getTenantId() 返回租户 ID 表达式,getTenantIdColumn() 默认返回 tenant_id,ignoreTable() 用于判断表是否忽略租户条件,ignoreInsert() 用于控制插入时是否忽略租户字段。(MyBatis-Plus)
文件位置:src/main/java/io/github/atengk/framework/tenant/CurrentTenantContext.java
package io.github.atengk.framework.tenant;
import cn.hutool.core.util.ObjectUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 当前租户上下文
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class CurrentTenantContext {
private static final ThreadLocal<Long> TENANT_HOLDER = new ThreadLocal<>();
/**
* 设置当前租户 ID
*
* @param tenantId 租户 ID
*/
public void setTenantId(Long tenantId) {
TENANT_HOLDER.set(tenantId);
}
/**
* 获取当前租户 ID
*
* @return 租户 ID
*/
public Long getTenantId() {
return TENANT_HOLDER.get();
}
/**
* 获取当前租户 ID,未设置时返回默认租户
*
* @return 租户 ID
*/
public Long getTenantIdOrDefault() {
return ObjectUtil.defaultIfNull(getTenantId(), 0L);
}
/**
* 清理当前租户上下文
*/
public void clear() {
TENANT_HOLDER.remove();
log.trace("当前租户上下文已清理");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
租户处理器如下。
文件位置:src/main/java/io/github/atengk/framework/mybatis/MyTenantLineHandler.java
package io.github.atengk.framework.mybatis;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.schema.Column;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
/**
* MyBatis-Plus 多租户处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MyTenantLineHandler implements TenantLineHandler {
/**
* 固定忽略租户隔离的系统表
*/
private static final Set<String> IGNORE_TABLES = Set.of(
"sys_tenant",
"sys_tenant_package",
"sys_menu",
"sys_role_menu",
"sys_dict_type",
"sys_dict_data",
"sys_config",
"flyway_schema_history"
);
private final CurrentTenantContext currentTenantContext;
/**
* 获取租户 ID 表达式
*
* @return 租户 ID 表达式
*/
@Override
public Expression getTenantId() {
Long tenantId = currentTenantContext.getTenantIdOrDefault();
return new LongValue(tenantId);
}
/**
* 获取租户字段名
*
* @return 租户字段名
*/
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
/**
* 判断是否忽略租户条件
*
* @param tableName 表名
* @return 是否忽略
*/
@Override
public boolean ignoreTable(String tableName) {
return IGNORE_TABLES.contains(tableName);
}
/**
* 判断插入时是否忽略租户字段
*
* @param columns 插入字段
* @param tenantIdColumn 租户字段名
* @return 是否忽略
*/
@Override
public boolean ignoreInsert(List<Column> columns, String tenantIdColumn) {
if (CollUtil.isEmpty(columns)) {
return false;
}
return columns.stream()
.map(Column::getColumnName)
.anyMatch(columnName -> columnName.equalsIgnoreCase(tenantIdColumn));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
注册插件:
文件位置:src/main/java/io/github/atengk/framework/mybatis/MyBatisPlusConfig.java
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 配置
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class MyBatisPlusConfig {
private final MyTenantLineHandler myTenantLineHandler;
/**
* 配置 MyBatis-Plus 拦截器
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 多租户插件:自动追加 tenant_id 条件
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(myTenantLineHandler));
// 乐观锁插件:处理 @Version 字段
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 分页插件:建议放在租户插件之后
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
log.info("MyBatis-Plus插件初始化完成,已启用多租户、乐观锁、分页插件");
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
多租户插件会改写 SQL,因此建议放在分页插件之前。这样分页数据和分页 COUNT 都能基于租户过滤后的 SQL 执行。MyBatis-Plus 插件核心文档也说明,@InterceptorIgnore 可以用于忽略特定插件,且从 3.5.3 起也支持手动设置忽略策略,但手动设置需要在调用后关闭。(MyBatis-Plus)
租户 ID 获取
租户 ID 获取方式取决于系统架构。常见方式包括登录用户绑定租户、请求头传租户、域名解析租户、网关透传租户、Token 中解析租户等。租户 ID 不应直接信任前端请求参数,必须经过登录态、签名、网关或服务端校验。
常见获取方式如下:
| 方式 | 说明 | 建议 |
|---|---|---|
| 登录用户绑定 | 登录后用户上下文中包含租户 ID | 推荐 |
| JWT Token | Token Claim 中包含租户 ID | 推荐,但需校验签名 |
| 网关 Header | 网关认证后透传 X-Tenant-Id | 可用,后端需信任网关 |
| 域名解析 | 通过 tenant-a.example.com 解析租户 | SaaS 常用 |
| 请求参数 | URL 或 Body 中传租户 ID | 不推荐直接信任 |
| 后台任务 | 任务执行时显式指定租户 | 必须明确设置 |
拦截器示例:从 Header 中读取租户 ID。实际项目中应优先从登录态或安全框架中获取。
文件位置:src/main/java/io/github/atengk/framework/web/TenantContextInterceptor.java
package io.github.atengk.framework.web;
import cn.hutool.core.convert.Convert;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 租户上下文拦截器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TenantContextInterceptor implements HandlerInterceptor {
private final CurrentTenantContext currentTenantContext;
/**
* 请求进入时设置租户上下文
*
* @param request 请求对象
* @param response 响应对象
* @param handler 处理器
* @return 是否继续执行
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
Long tenantId = Convert.toLong(request.getHeader("X-Tenant-Id"), 0L);
currentTenantContext.setTenantId(tenantId);
log.trace("租户上下文设置完成,租户ID:{}", tenantId);
return true;
}
/**
* 请求完成后清理租户上下文
*
* @param request 请求对象
* @param response 响应对象
* @param handler 处理器
* @param ex 异常对象
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
currentTenantContext.clear();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
注册拦截器:
文件位置:src/main/java/io/github/atengk/framework/web/WebMvcConfig.java
package io.github.atengk.framework.web;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final TenantContextInterceptor tenantContextInterceptor;
/**
* 注册 Web 拦截器
*
* @param registry 拦截器注册器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantContextInterceptor)
.addPathPatterns("/api/**");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
如果系统使用 Sa-Token、Spring Security 或网关认证,建议在认证成功后统一设置 CurrentTenantContext,不要在业务 Controller 中手动设置租户。
租户上下文传递
租户上下文一般使用 ThreadLocal 保存。Web 请求中,同一个请求线程内可以直接读取;但异步任务、线程池、MQ 消费、定时任务、新线程不会自动继承当前请求的租户上下文,因此必须显式传递。
租户上下文传递建议:
| 场景 | 建议 |
|---|---|
| Web 请求 | 拦截器中设置,请求结束清理 |
| Feign 调用 | 请求头透传租户 ID |
| MQ 生产 | 消息体或 Header 中携带租户 ID |
| MQ 消费 | 消费前设置租户上下文,消费后清理 |
| 异步任务 | 提交任务时携带租户 ID |
| 定时任务 | 遍历租户执行,逐个设置上下文 |
| 线程池 | 使用任务包装器复制上下文 |
| 单元测试 | 测试前手动设置上下文,测试后清理 |
异步任务包装器示例:
文件位置:src/main/java/io/github/atengk/framework/tenant/TenantTaskDecorator.java
package io.github.atengk.framework.tenant;
import lombok.RequiredArgsConstructor;
import org.springframework.core.task.TaskDecorator;
import org.springframework.stereotype.Component;
/**
* 租户上下文任务装饰器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
@RequiredArgsConstructor
public class TenantTaskDecorator implements TaskDecorator {
private final CurrentTenantContext currentTenantContext;
/**
* 装饰异步任务,传递租户上下文
*
* @param runnable 原始任务
* @return 包装后的任务
*/
@Override
public Runnable decorate(Runnable runnable) {
Long tenantId = currentTenantContext.getTenantId();
return () -> {
try {
currentTenantContext.setTenantId(tenantId);
runnable.run();
} finally {
currentTenantContext.clear();
}
};
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
线程池配置示例:
文件位置:src/main/java/io/github/atengk/framework/async/AsyncConfig.java
package io.github.atengk.framework.async;
import io.github.atengk.framework.tenant.TenantTaskDecorator;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* 异步线程池配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
@RequiredArgsConstructor
public class AsyncConfig {
private final TenantTaskDecorator tenantTaskDecorator;
/**
* 业务异步线程池
*
* @return 线程池
*/
@Bean("bizTaskExecutor")
public ThreadPoolTaskExecutor bizTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("biz-task-");
executor.setTaskDecorator(tenantTaskDecorator);
executor.initialize();
return executor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
租户上下文必须在任务结束后清理。线程池线程会复用,如果不清理,可能导致租户串数据。
租户数据隔离
租户数据隔离通常分为三种模型:独立数据库、独立 Schema、共享数据库共享表。MyBatis-Plus TenantLineInnerInterceptor 主要适用于共享数据库、共享表、通过 tenant_id 字段隔离的模式。
常见隔离模型:
| 模型 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 独立数据库 | 每个租户一个数据库 | 隔离最强 | 运维成本高 |
| 独立 Schema | 每个租户一个 Schema | 隔离较强 | 数据源和迁移复杂 |
| 共享表 | 所有租户共享表,通过 tenant_id 隔离 | 成本低,开发简单 | 对 SQL 和权限要求高 |
共享表隔离时,插件会把普通查询:
SELECT id, order_no, pay_amount
FROM biz_order
WHERE deleted = 02
3
改写为类似:
SELECT id, order_no, pay_amount
FROM biz_order
WHERE deleted = 0
AND tenant_id = 10012
3
4
插入时,如果 SQL 中没有租户字段,插件可以自动追加租户字段。MyBatis-Plus 多租户文档中 ignoreInsert 默认逻辑会检查插入字段中是否已经包含租户字段,如果已包含则忽略自动追加。(MyBatis-Plus)
例如插入:
INSERT INTO biz_order (id, order_no, pay_amount)
VALUES (?, ?, ?)2
可能被补充为类似:
INSERT INTO biz_order (id, order_no, pay_amount, tenant_id)
VALUES (?, ?, ?, 1001)2
租户数据隔离注意事项:
| 场景 | 建议 |
|---|---|
| 查询 | 自动追加 tenant_id = 当前租户 |
| 插入 | 自动填充或插件追加 tenant_id |
| 修改 | 自动追加租户条件,防止跨租户更新 |
| 删除 | 自动追加租户条件,防止跨租户删除 |
| 唯一索引 | 一般带上 tenant_id |
| 自定义 SQL | 必须测试插件改写结果 |
| 平台表 | 加入忽略表配置 |
| 跨租户运维 | 使用专门接口和明确忽略策略 |
不要只依赖前端传入租户 ID。租户隔离必须由后端统一控制。
忽略租户表配置
不是所有表都需要租户隔离。平台级表、系统公共表、租户表本身、菜单表、公共字典表、系统配置表、数据库迁移表等通常需要忽略租户条件。
常见忽略表:
| 表名 | 原因 |
|---|---|
sys_tenant | 租户表本身是平台级表 |
sys_tenant_package | 租户套餐通常是平台级配置 |
sys_menu | 菜单通常是平台公共资源 |
sys_role_menu | 如果角色菜单关系为平台模板,可忽略 |
sys_dict_type | 全局字典类型 |
sys_dict_data | 全局字典数据 |
sys_config | 平台配置 |
flyway_schema_history | 数据库迁移表 |
qrtz_* | Quartz 调度表,视项目决定 |
xxl_job_* | XXL-JOB 表,视部署方式决定 |
在 TenantLineHandler#ignoreTable 中配置:
@Override
public boolean ignoreTable(String tableName) {
return IGNORE_TABLES.contains(tableName);
}2
3
4
对于单个 Mapper 方法临时忽略租户插件,可以使用 MyBatis-Plus 的 @InterceptorIgnore。官方插件核心文档说明,@InterceptorIgnore 可用于忽略特定插件,相关属性设置为 true 时表示忽略对应插件。(MyBatis-Plus)
示例:
package io.github.atengk.module.system.tenant.mapper;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.atengk.module.system.tenant.entity.TenantEntity;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 租户 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface TenantMapper extends BaseMapper<TenantEntity> {
/**
* 查询所有租户
*
* @return 租户列表
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT id, tenant_name, tenant_code, status
FROM sys_tenant
WHERE deleted = 0
ORDER BY create_time DESC
""")
List<TenantEntity> selectAllTenant();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
忽略租户规则建议:
| 规范项 | 建议 |
|---|---|
| 忽略表 | 在 Handler 中集中配置 |
| 忽略方法 | 使用 @InterceptorIgnore(tenantLine = "true") |
| 忽略范围 | 尽量小,避免整个 Mapper 放开 |
| 管理接口 | 加强接口权限控制 |
| 审计 | 跨租户查询应记录操作日志 |
| 导出 | 默认不要忽略租户 |
忽略租户是高风险操作。任何跨租户查询、跨租户导出、跨租户修改都应有明确权限控制和操作日志。
租户与登录用户绑定
租户与登录用户绑定用于确定当前用户属于哪个租户。一般情况下,用户登录成功后,后端将用户 ID、租户 ID、角色、权限等信息放入登录上下文。后续请求通过 Token 或 Session 恢复上下文,再设置租户上下文。
用户表示例:
CREATE TABLE sys_user (
id BIGINT NOT NULL COMMENT '用户ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
username VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
password VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_username (tenant_id, username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';2
3
4
5
6
7
8
9
10
登录用户上下文:
文件位置:src/main/java/io/github/atengk/framework/security/LoginUser.java
package io.github.atengk.framework.security;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 当前登录用户
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class LoginUser implements Serializable {
/**
* 用户 ID
*/
private Long userId;
/**
* 租户 ID
*/
private Long tenantId;
/**
* 用户名
*/
private String username;
/**
* 是否超级管理员
*/
private Boolean superAdmin;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
登录成功后设置租户上下文:
public void afterLoginSuccess(LoginUser loginUser) {
currentUserContext.setLoginUser(loginUser);
currentTenantContext.setTenantId(loginUser.getTenantId());
log.info("用户登录成功,用户ID:{},租户ID:{}", loginUser.getUserId(), loginUser.getTenantId());
}2
3
4
5
6
租户与登录用户绑定建议:
| 场景 | 建议 |
|---|---|
| 普通租户用户 | 用户固定绑定一个租户 |
| 平台管理员 | 可以不绑定租户,或使用平台租户 ID |
| 多租户管理员 | 登录后选择当前操作租户 |
| 一个账号多个租户 | Token 中必须包含当前租户 |
| 切换租户 | 重新签发 Token 或刷新上下文 |
| 用户名唯一性 | 推荐租户内唯一,即 (tenant_id, username) |
如果同一个手机号可以加入多个租户,登录时必须明确选择租户,不能只根据手机号定位唯一用户。
租户与定时任务
定时任务没有 Web 请求上下文,因此不会自动有租户 ID。多租户系统中的定时任务必须明确是平台任务还是租户任务。
常见任务类型:
| 类型 | 说明 | 处理方式 |
|---|---|---|
| 平台任务 | 清理平台配置、统计租户数量 | 忽略租户或操作平台表 |
| 单租户任务 | 处理指定租户数据 | 执行前设置租户上下文 |
| 全租户任务 | 每个租户都执行一次 | 遍历租户逐个设置上下文 |
| 跨租户统计 | 平台看板统计 | 使用专门权限和忽略租户 |
全租户任务示例:
文件位置:src/main/java/io/github/atengk/module/job/TenantOrderStatisticsJob.java
package io.github.atengk.module.job;
import cn.hutool.core.collection.CollUtil;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import io.github.atengk.module.system.tenant.entity.TenantEntity;
import io.github.atengk.module.system.tenant.service.TenantService;
import io.github.atengk.module.order.service.OrderStatisticsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 租户订单统计定时任务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TenantOrderStatisticsJob {
private final TenantService tenantService;
private final OrderStatisticsService orderStatisticsService;
private final CurrentTenantContext currentTenantContext;
/**
* 每天凌晨统计所有租户订单数据
*/
@Scheduled(cron = "0 0 2 * * ?")
public void statisticsAllTenantOrders() {
List<TenantEntity> tenants = tenantService.listEnabledTenants();
if (CollUtil.isEmpty(tenants)) {
log.info("没有需要统计的租户");
return;
}
for (TenantEntity tenant : tenants) {
try {
currentTenantContext.setTenantId(tenant.getId());
orderStatisticsService.statisticsYesterdayOrder();
log.info("租户订单统计完成,租户ID:{}", tenant.getId());
} catch (Exception exception) {
log.error("租户订单统计失败,租户ID:{}", tenant.getId(), exception);
} finally {
currentTenantContext.clear();
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
定时任务注意事项:
| 规范项 | 建议 |
|---|---|
| 执行前 | 明确设置租户上下文 |
| 执行后 | 必须清理租户上下文 |
| 全租户任务 | 遍历租户逐个执行 |
| 异常处理 | 单个租户失败不影响其他租户 |
| 日志 | 必须记录租户 ID |
| 平台任务 | 明确忽略租户或只操作忽略表 |
| 分布式任务 | 避免多个节点重复处理同一租户 |
租户与异步任务
异步任务使用线程池执行,不会自动继承 Web 请求中的 ThreadLocal 租户上下文。必须通过任务装饰器、参数传递或消息体传递租户 ID。
错误示例:
@Async
public void asyncCreateReport() {
// 这里可能拿不到租户ID,或拿到线程复用残留的租户ID
reportService.createReport();
}2
3
4
5
推荐方式一:显式传递租户 ID。
@Async("bizTaskExecutor")
public void asyncCreateReport(Long tenantId, Long reportId) {
try {
currentTenantContext.setTenantId(tenantId);
reportService.createReport(reportId);
log.info("异步生成报表完成,租户ID:{},报表ID:{}", tenantId, reportId);
} finally {
currentTenantContext.clear();
}
}2
3
4
5
6
7
8
9
10
推荐方式二:使用前文的 TenantTaskDecorator 自动传递上下文。
@Async("bizTaskExecutor")
public void asyncRefreshCache(Long cacheId) {
cacheRefreshService.refresh(cacheId);
log.info("异步刷新缓存完成,缓存ID:{}", cacheId);
}2
3
4
5
异步任务建议:
| 场景 | 建议 |
|---|---|
| 简单异步 | 显式传入 tenantId |
| 统一线程池 | 配置 TaskDecorator |
| MQ 消费 | 从消息 Header 或 Body 获取租户 |
| 批量异步 | 每个任务携带租户 ID |
| 任务结束 | 必须清理上下文 |
| 异常日志 | 记录租户 ID 和业务 ID |
只要使用线程池,就必须考虑租户上下文泄漏。线程复用导致的租户串数据是多租户系统中的高风险问题。
租户场景测试
租户测试必须覆盖查询隔离、插入填充、修改隔离、删除隔离、忽略表、定时任务和异步任务。只测试普通查询是不够的。
测试建议使用独立测试库,并准备两个租户的数据:
INSERT INTO sys_tenant (id, tenant_name, tenant_code, status, deleted)
VALUES
(1001, '租户A', 'tenant_a', 1, 0),
(1002, '租户B', 'tenant_b', 1, 0);
INSERT INTO biz_order (id, tenant_id, order_no, pay_amount, order_status, deleted, create_time)
VALUES
(2001, 1001, 'A_ORDER_001', 100.00, 20, 0, NOW()),
(2002, 1002, 'B_ORDER_001', 200.00, 20, 0, NOW());2
3
4
5
6
7
8
9
测试类示例:
文件位置:src/test/java/io/github/atengk/module/tenant/TenantIsolationTest.java
package io.github.atengk.module.tenant;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import io.github.atengk.module.order.entity.OrderEntity;
import io.github.atengk.module.order.service.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.math.BigDecimal;
import java.util.List;
/**
* 租户隔离测试
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@SpringBootTest
@ActiveProfiles("test")
@MapperScan("io.github.atengk.**.mapper")
class TenantIsolationTest {
@Autowired
private CurrentTenantContext currentTenantContext;
@Autowired
private OrderService orderService;
/**
* 每个测试完成后清理租户上下文
*/
@AfterEach
void afterEach() {
currentTenantContext.clear();
}
/**
* 测试不同租户只能查询自己的订单
*/
@Test
void testTenantQueryIsolation() {
currentTenantContext.setTenantId(1001L);
List<OrderEntity> tenantAOrders = orderService.list();
currentTenantContext.setTenantId(1002L);
List<OrderEntity> tenantBOrders = orderService.list();
Assertions.assertTrue(tenantAOrders.stream().allMatch(item -> item.getTenantId().equals(1001L)));
Assertions.assertTrue(tenantBOrders.stream().allMatch(item -> item.getTenantId().equals(1002L)));
log.info("租户查询隔离测试完成,租户A数量:{},租户B数量:{}",
tenantAOrders.size(), tenantBOrders.size());
}
/**
* 测试新增数据自动写入当前租户 ID
*/
@Test
void testTenantInsertFill() {
currentTenantContext.setTenantId(1001L);
OrderEntity order = new OrderEntity();
order.setOrderNo("A_ORDER_TEST");
order.setPayAmount(new BigDecimal("99.00"));
order.setOrderStatus(20);
orderService.save(order);
OrderEntity savedOrder = orderService.getById(order.getId());
Assertions.assertNotNull(savedOrder);
Assertions.assertEquals(1001L, savedOrder.getTenantId());
log.info("租户新增填充测试完成,订单ID:{},租户ID:{}", savedOrder.getId(), savedOrder.getTenantId());
}
/**
* 测试租户之间不能修改对方数据
*/
@Test
void testTenantUpdateIsolation() {
currentTenantContext.setTenantId(1001L);
OrderEntity order = new OrderEntity();
order.setOrderNo("A_ORDER_UPDATE_TEST");
order.setPayAmount(new BigDecimal("88.00"));
order.setOrderStatus(20);
orderService.save(order);
currentTenantContext.setTenantId(1002L);
boolean updated = orderService.lambdaUpdate()
.eq(OrderEntity::getId, order.getId())
.set(OrderEntity::getPayAmount, new BigDecimal("188.00"))
.update();
Assertions.assertFalse(updated);
currentTenantContext.setTenantId(1001L);
OrderEntity currentOrder = orderService.getById(order.getId());
Assertions.assertEquals(new BigDecimal("88.00"), currentOrder.getPayAmount());
log.info("租户修改隔离测试完成,订单ID:{}", order.getId());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
测试重点如下:
| 测试项 | 验证内容 |
|---|---|
| 查询隔离 | 租户 A 查不到租户 B 数据 |
| 新增填充 | 新增数据自动写入当前租户 ID |
| 修改隔离 | 租户 A 不能修改租户 B 数据 |
| 删除隔离 | 租户 A 不能删除租户 B 数据 |
| 分页隔离 | 分页总数和列表都受租户过滤 |
| 关联查询 | 主表租户条件正确追加 |
| 忽略表 | sys_tenant 等平台表不被过滤 |
| 异步任务 | 异步线程能正确获得租户上下文 |
| 定时任务 | 每个租户单独执行且上下文清理 |
| 自定义 SQL | XML SQL 被正确改写 |
多租户测试必须开启 SQL 日志观察实际 SQL。重点确认 tenant_id 是否追加到了正确表上,分页 COUNT 是否包含租户条件,自定义 SQL 是否被正确解析,忽略表是否没有被错误追加租户条件。
多数据源
多数据源用于一个应用连接多个数据库实例或多个数据库逻辑库。常见场景包括主从读写分离、业务分库、报表库查询、历史库查询、租户独立库、日志库写入、跨系统数据同步等。MyBatis-Plus 本身负责 ORM 增强,多数据源切换通常通过 dynamic-datasource、mybatis-mate 或自定义 AbstractRoutingDataSource 实现。普通 Spring Boot 3 项目推荐优先使用 dynamic-datasource-spring-boot3-starter,配置成本低,生态兼容性较好。
多数据源使用场景
多数据源不是为了“架构好看”而引入。只有当单一数据源无法满足业务、性能、隔离、运维或安全要求时,才应引入多数据源。
常见使用场景如下:
| 场景 | 说明 | 示例 |
|---|---|---|
| 主从读写分离 | 写主库,读从库 | master、slave_1、slave_2 |
| 业务分库 | 不同业务模块使用不同数据库 | 用户库、订单库、报表库 |
| 报表库查询 | 报表查询不压业务主库 | report 数据源 |
| 历史库查询 | 冷数据迁移到历史库 | history 数据源 |
| 日志库写入 | 操作日志、审计日志单独存储 | log 数据源 |
| 多租户独立库 | 每个租户独立数据库 | tenant_1001、tenant_1002 |
| 数据同步 | 同时读旧库、写新库 | legacy、master |
| 第三方系统库 | 读取外部系统数据库 | external 数据源 |
不建议在项目早期过度引入多数据源。多数据源会增加事务复杂度、故障排查成本、连接池资源消耗、SQL 路由理解成本和测试复杂度。
动态数据源配置
Spring Boot 3 项目引入依赖如下。
文件位置:pom.xml
<!-- MyBatis-Plus Spring Boot 3 集成 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Dynamic Datasource Spring Boot 3 多数据源集成 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
<version>${dynamic-datasource.version}</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
推荐版本属性示例:
<properties>
<mybatis-plus.version>3.5.14</mybatis-plus.version>
<dynamic-datasource.version>4.3.1</dynamic-datasource.version>
</properties>2
3
4
基础配置如下。
文件位置:src/main/resources/application.yml
spring:
datasource:
dynamic:
# 默认数据源名称
primary: master
# 严格模式:true 表示未匹配到数据源时报错;false 表示使用默认数据源
strict: true
# 是否开启 SQL 初始化
sql-script-encoding: UTF-8
datasource:
master:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/app_master?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
slave_1:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/app_slave_1?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
slave_2:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/app_slave_2?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
report:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/app_report?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 1234562
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
配置说明:
| 配置项 | 说明 |
|---|---|
primary | 默认数据源名称,未指定 @DS 时使用 |
strict | 严格模式,建议生产环境设置为 true |
master | 主库数据源 |
slave_1、slave_2 | 从库数据源,属于 slave 组 |
report | 报表库数据源 |
driver-class-name | MySQL 8 推荐使用 com.mysql.cj.jdbc.Driver |
dynamic-datasource 的分组规则是按数据源名称下划线前缀分组。例如 slave_1 和 slave_2 都属于 slave 组,使用 @DS("slave") 时会在组内选择数据源。(MyBatis-Plus)
主从数据源配置
主从配置通常用于写主库、读从库。主库负责新增、修改、删除,从库负责查询。配置层面可以使用一个 master 和多个 slave_x。
spring:
datasource:
dynamic:
primary: master
strict: true
datasource:
master:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/app_master?serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
slave_1:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/app_slave_1?serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
slave_2:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/app_slave_2?serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 1234562
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
主从使用建议:
| 操作 | 推荐数据源 |
|---|---|
| 新增 | master |
| 修改 | master |
| 删除 | master |
| 详情查询 | slave,但强一致场景走 master |
| 分页查询 | slave |
| 报表查询 | report 或 slave |
| 写后立即读 | master |
| 事务内查询 | master |
主从复制存在延迟。写入后立即查询详情、支付后立即查询订单状态、权限修改后立即读取权限等强一致场景,应走主库。
读写分离配置
读写分离可以通过 @DS 在 Service 层手动指定,也可以结合 AOP 规则自动路由。普通项目更推荐显式注解,语义清晰,排查简单。
定义数据源名称常量。
文件位置:src/main/java/io/github/atengk/framework/datasource/DataSourceNames.java
package io.github.atengk.framework.datasource;
/**
* 数据源名称常量
*
* @author Ateng
* @since 2026-05-05
*/
public final class DataSourceNames {
/**
* 主库
*/
public static final String MASTER = "master";
/**
* 从库组
*/
public static final String SLAVE = "slave";
/**
* 报表库
*/
public static final String REPORT = "report";
/**
* 历史库
*/
public static final String HISTORY = "history";
private DataSourceNames() {
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
读写分离 Service 示例:
文件位置:src/main/java/io/github/atengk/module/system/user/service/impl/UserReadWriteService.java
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.framework.datasource.DataSourceNames;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
/**
* 用户读写分离服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class UserReadWriteService extends ServiceImpl<UserMapper, UserEntity> {
/**
* 主库新增用户
*
* @param entity 用户实体
* @return 用户 ID
*/
@DS(DataSourceNames.MASTER)
public Long addUser(UserEntity entity) {
this.save(entity);
log.info("主库新增用户成功,用户ID:{}", entity.getId());
return entity.getId();
}
/**
* 从库查询用户列表
*
* @return 用户列表
*/
@DS(DataSourceNames.SLAVE)
public List<UserEntity> listUsersFromSlave() {
List<UserEntity> users = this.lambdaQuery()
.orderByDesc(UserEntity::getCreateTime)
.list();
if (CollUtil.isEmpty(users)) {
return Collections.emptyList();
}
log.info("从库查询用户列表成功,数量:{}", users.size());
return users;
}
/**
* 写后强一致查询,走主库
*
* @param id 用户 ID
* @return 用户实体
*/
@DS(DataSourceNames.MASTER)
public UserEntity getUserAfterWrite(Long id) {
return this.getById(id);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
读写分离注意事项:
| 场景 | 建议 |
|---|---|
| 写后立即读 | 走主库 |
| 普通列表查询 | 可走从库 |
| 事务内读写 | 建议走主库 |
| 权限、余额、库存查询 | 强一致时走主库 |
| 报表查询 | 走报表库或从库 |
| 主从延迟 | 需要监控和降级策略 |
数据源切换注解
dynamic-datasource 使用 @DS 切换数据源。@DS 可以写在类上或方法上;同时存在时,方法注解优先于类注解。官方文档也建议优先将注解写在 Service 实现类上,而不是 Controller 或 Mapper 上。(MyBatis-Plus)
类级别切换:
@Service
@DS(DataSourceNames.SLAVE)
public class UserQueryService {
}2
3
4
方法级别切换:
@DS(DataSourceNames.REPORT)
public List<UserReportVO> listUserReport(UserReportQuery query) {
return userReportMapper.selectUserReportList(query);
}2
3
4
类上默认从库,方法上覆盖为主库:
@Slf4j
@Service
@DS(DataSourceNames.SLAVE)
@RequiredArgsConstructor
public class UserMixedDataSourceService {
private final UserMapper userMapper;
/**
* 默认从库查询
*
* @return 用户列表
*/
public List<UserEntity> listUsers() {
return userMapper.selectList(null);
}
/**
* 方法级别覆盖为主库
*
* @param entity 用户实体
* @return 用户 ID
*/
@DS(DataSourceNames.MASTER)
public Long saveUser(UserEntity entity) {
userMapper.insert(entity);
log.info("主库保存用户成功,用户ID:{}", entity.getId());
return entity.getId();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
注解使用建议:
| 位置 | 是否推荐 | 说明 |
|---|---|---|
| Controller | 不推荐 | Controller 不应感知数据源 |
| Service 实现类 | 推荐 | 官方也建议写在 Service 实现上 |
| Service 方法 | 推荐 | 最清晰 |
| Mapper 接口 | 谨慎 | 可用,但业务语义不明显 |
| Mapper 方法 | 谨慎 | 适合极少数固定库 SQL |
| 私有方法 | 无效或不可靠 | AOP 代理通常不处理私有方法 |
Mapper 维度数据源切换
Mapper 维度切换适合某个 Mapper 固定访问某个库,例如报表 Mapper 固定访问报表库、历史 Mapper 固定访问历史库、外部系统 Mapper 固定访问外部库。虽然可以使用,但业务项目中应谨慎,因为数据源切换放在 Mapper 层会降低 Service 层可读性。
Mapper 示例:
文件位置:src/main/java/io/github/atengk/module/report/mapper/UserReportMapper.java
package io.github.atengk.module.report.mapper;
import com.baomidou.dynamic.datasource.annotation.DS;
import io.github.atengk.framework.datasource.DataSourceNames;
import io.github.atengk.module.report.query.UserReportQuery;
import io.github.atengk.module.report.vo.UserReportVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 用户报表 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
@DS(DataSourceNames.REPORT)
public interface UserReportMapper {
/**
* 查询用户报表
*
* @param query 查询参数
* @return 用户报表列表
*/
List<UserReportVO> selectUserReportList(@Param("query") UserReportQuery query);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Mapper XML:
<mapper namespace="io.github.atengk.module.report.mapper.UserReportMapper">
<select id="selectUserReportList" resultType="io.github.atengk.module.report.vo.UserReportVO">
SELECT
user_id,
username,
order_count,
total_amount,
stat_date
FROM rpt_user_order_day
WHERE stat_date >= #{query.startDate}
AND stat_date <= #{query.endDate}
ORDER BY stat_date DESC, total_amount DESC
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Mapper 维度切换建议:
| 场景 | 建议 |
|---|---|
| 报表 Mapper 固定报表库 | 可以 |
| 历史 Mapper 固定历史库 | 可以 |
| 外部系统 Mapper 固定外部库 | 可以 |
| 普通业务 Mapper | 不建议 |
| 同一 Mapper 有读写分离 | 不建议放 Mapper,放 Service |
| 跨库业务流程 | 放 Service 明确控制 |
Service 维度数据源切换
Service 维度切换是最推荐的方式。Service 层最清楚业务语义,例如“这个方法是报表查询”“这个方法是主库写入”“这个方法是写后强一致查询”。
示例:
文件位置:src/main/java/io/github/atengk/module/report/service/impl/UserReportServiceImpl.java
package io.github.atengk.module.report.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.dynamic.datasource.annotation.DS;
import io.github.atengk.framework.datasource.DataSourceNames;
import io.github.atengk.module.report.mapper.UserReportMapper;
import io.github.atengk.module.report.query.UserReportQuery;
import io.github.atengk.module.report.service.UserReportService;
import io.github.atengk.module.report.vo.UserReportVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
/**
* 用户报表服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserReportServiceImpl implements UserReportService {
private final UserReportMapper userReportMapper;
/**
* 查询用户报表,固定走报表库
*
* @param query 查询参数
* @return 用户报表列表
*/
@Override
@DS(DataSourceNames.REPORT)
public List<UserReportVO> listUserReport(UserReportQuery query) {
List<UserReportVO> records = userReportMapper.selectUserReportList(query);
if (CollUtil.isEmpty(records)) {
return Collections.emptyList();
}
log.info("查询用户报表完成,数量:{}", records.size());
return records;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Service 维度切换注意事项:
| 规范项 | 建议 |
|---|---|
| 写操作 | 明确 @DS("master") |
| 读操作 | 可使用 @DS("slave") |
| 报表查询 | 使用 @DS("report") |
| 历史查询 | 使用 @DS("history") |
| 写后读 | 使用 @DS("master") |
| 事务方法 | 数据源切换应在事务开启前确定 |
| 内部调用 | 避免 this.method() 导致 AOP 不生效 |
数据源切换和事务都依赖 AOP。若同类内部方法调用,可能导致 @DS 和 @Transactional 都不生效。
多数据源事务处理
多数据源事务是多数据源中最容易踩坑的部分。普通 Spring 本地事务通常只管理当前数据源连接,不能天然保证多个数据源同时提交或同时回滚。dynamic-datasource 侧重数据源切换,也提供 Seata 分布式事务方案;官方资料中也提到它提供基于 Seata 的分布式事务方案,并提供本地多数据源事务能力。(MyBatis-Plus)
单数据源事务示例:
@DS(DataSourceNames.MASTER)
@Transactional(rollbackFor = Exception.class)
public void updateUser(UserUpdateDTO dto) {
UserEntity entity = this.getById(dto.getId());
if (entity == null) {
throw new BusinessException("用户不存在");
}
entity.setNickname(dto.getNickname());
this.updateById(entity);
log.info("主库事务修改用户成功,用户ID:{}", dto.getId());
}2
3
4
5
6
7
8
9
10
11
12
13
跨数据源写入示例:
@Transactional(rollbackFor = Exception.class)
public void saveBusinessAndLog(UserEntity user, OperationLogEntity logEntity) {
userService.saveUserToMaster(user);
operationLogService.saveLogToLogDb(logEntity);
}2
3
4
5
上面这种写法在跨多个真实数据源时不一定能保证两个库同时回滚。更稳妥的方案是:
| 场景 | 推荐方案 |
|---|---|
| 主业务库 + 日志库 | 日志独立事务或异步写入 |
| 主库 + 报表库 | 写主库后通过 MQ 或任务同步报表库 |
| 订单库 + 库存库 | 事务消息、Seata、TCC 或 Saga |
| 同一业务强一致跨库 | 分布式事务 |
| 可最终一致 | 本地消息表、MQ、补偿任务 |
| 查询多个库 | 通常不需要事务 |
日志库独立事务示例:
@DS("log")
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void saveOperationLog(OperationLogEntity logEntity) {
this.save(logEntity);
log.info("日志库保存操作日志成功,业务ID:{}", logEntity.getBizId());
}2
3
4
5
6
业务库写入后异步同步报表库示例:
@DS(DataSourceNames.MASTER)
@Transactional(rollbackFor = Exception.class)
public Long createOrder(OrderCreateDTO dto) {
OrderEntity order = orderConvert.toEntity(dto);
orderService.save(order);
MessageOutboxEntity message = new MessageOutboxEntity();
message.setBizType("ORDER_CREATED");
message.setBizId(order.getId());
message.setTopic("order.created");
message.setMessageBody(JSONUtil.toJsonStr(order));
messageOutboxService.save(message);
log.info("创建订单并写入消息表成功,订单ID:{}", order.getId());
return order.getId();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
多数据源事务建议:
| 规范项 | 建议 |
|---|---|
| 单方法只操作一个数据源 | 最简单、最安全 |
| 跨库强一致 | 使用分布式事务方案 |
| 跨库最终一致 | 使用 MQ、消息表、补偿任务 |
| 日志写入 | 不要拖累主业务事务 |
| 报表同步 | 异步处理 |
| 事务内切换数据源 | 谨慎,容易与连接绑定冲突 |
事务方法上的 @DS | 应让数据源在事务开启前确定 |
一个关键原则:事务开启后,当前线程通常已经绑定了数据库连接。此时再尝试切换数据源,可能不会按预期生效。因此事务方法应尽量只使用一个明确数据源。
多数据源分页插件配置
MyBatis-Plus 分页插件 PaginationInnerInterceptor 支持多种数据库。从 3.5.9 起,分页插件被拆分出来,需要单独引入 mybatis-plus-jsqlparser。官方分页插件文档说明:如果配置多个插件,分页插件应最后添加;如果有多个数据源,可以不配置具体 DbType,否则建议指定具体数据库类型。(MyBatis-Plus)
多数据源都是 MySQL 时,可以指定 DbType.MYSQL:
文件位置:src/main/java/io/github/atengk/framework/mybatis/MyBatisPlusConfig.java
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 插件配置
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Configuration
public class MyBatisPlusConfig {
/**
* 配置 MyBatis-Plus 拦截器
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
log.info("MyBatis-Plus分页插件初始化完成,数据库类型:MYSQL");
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
如果多个数据源包含不同数据库类型,例如 MySQL、PostgreSQL、Oracle 混用,可以不指定具体 DbType:
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
return interceptor;
}2
3
4
5
6
7
8
9
10
如果还同时使用多租户、动态表名、数据权限等 SQL 改写插件,应注意顺序。MyBatis-Plus 官方插件文档建议:多租户、动态表名优先;分页、乐观锁随后;SQL 性能规范和防全表更新删除最后。(MyBatis-Plus)
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 先添加会改写 SQL 的插件
// interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(...));
// interceptor.addInnerInterceptor(new DynamicTableNameInnerInterceptor(...));
// 分页插件放后面
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
// 防全表更新删除等插件最后添加
// interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
return interceptor;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
多数据源分页注意事项:
| 场景 | 建议 |
|---|---|
| 所有库都是 MySQL | 指定 DbType.MYSQL |
| 多种数据库混用 | 不指定 DbType,让插件判断 |
| 多插件共存 | 分页插件放在 SQL 改写插件后 |
| 报表库分页 | 注意 COUNT SQL 性能 |
| 主从分页 | 查询走从库,写后强一致查询走主库 |
| 最大页大小 | 使用 maxLimit 兜底限制 |
| 深分页 | 报表库和历史库尤其要优化 |
多数据源常见问题
多数据源问题通常集中在数据源未切换、事务中切换无效、主从延迟、分页方言错误、Mapper 扫描混乱、连接池耗尽和上下文泄漏。
常见问题如下:
| 问题 | 原因 | 处理方式 |
|---|---|---|
@DS 不生效 | 同类内部方法调用,未经过 AOP 代理 | 拆到独立 Service,或通过代理调用 |
方法明明写了 @DS 仍走默认库 | 事务已先开启并绑定默认数据源 | 确保数据源切换发生在事务开启前 |
| 从库查不到刚写入数据 | 主从复制延迟 | 写后读走主库 |
| 分页 SQL 方言错误 | 多数据库类型混用但强制指定了错误 DbType | 混用数据库时不指定 DbType |
| 报表查询很慢 | 报表库索引或 COUNT SQL 不合理 | 优化索引、自定义 COUNT、异步报表 |
| 连接池耗尽 | 多数据源连接池配置过大或连接泄漏 | 限制池大小,检查慢 SQL 和事务 |
| Mapper 走错库 | Mapper 或 Service 注解位置混乱 | 统一在 Service 层控制 |
| 事务不回滚 | 跨多数据源但使用本地事务 | 使用分布式事务或最终一致 |
| 数据源名称拼错 | strict=false 时可能回退默认库 | 生产建议 strict=true |
| SQL 初始化错库 | schema/data 配置混乱 | 每个数据源独立确认初始化配置 |
生产环境建议启用严格模式:
spring:
datasource:
dynamic:
primary: master
strict: true2
3
4
5
排查数据源切换时,可以临时增加日志级别:
logging:
level:
com.baomidou.dynamic: debug
io.github.atengk: debug2
3
4
多数据源开发规范总结:
| 规范项 | 建议 |
|---|---|
| 数据源命名 | master、slave_1、slave_2、report、history |
| 默认数据源 | 明确配置 primary |
| 严格模式 | 生产环境开启 strict=true |
| 切换位置 | 优先 Service 方法 |
| 写操作 | 明确走 master |
| 普通读 | 可走 slave |
| 强一致读 | 走 master |
| 报表读 | 走 report |
| 跨库事务 | 不依赖普通本地事务 |
| 分页插件 | 单一数据库指定 DbType,多数据库混用可不指定 |
| 测试 | 必须覆盖主库写、从库读、写后读、报表查询、事务回滚 |
多数据源的核心原则是:数据源切换要显式、事务边界要清晰、跨库一致性要单独设计。普通业务读写分离可以使用 @DS;跨库写入不要假设本地事务能自动保证一致性,应采用分布式事务或最终一致方案。
动态表名
动态表名用于在运行时将逻辑表名替换成真实物理表名,常见于按时间分表、按租户分表、按业务类型分表、冷热数据拆分等场景。MyBatis-Plus 提供 DynamicTableNameInnerInterceptor,可以在 SQL 执行前根据配置规则动态替换表名;官方文档也提醒,动态表名规则要避免安全问题,并建议逻辑表名设计得更明确一些,避免误替换。(MyBatis-Plus)
动态表名使用场景
动态表名适合“表结构一致,但数据按某个维度拆分到不同物理表”的场景。它解决的是物理表路由问题,不解决跨表聚合、跨表分页、自动建表、分布式事务、全局唯一索引等问题。
常见场景如下:
| 场景 | 示例 |
|---|---|
| 按年月分表 | biz_order_202601、biz_order_202602 |
| 按租户分表 | biz_order_tenant_1001、biz_order_tenant_1002 |
| 按业务类型分表 | biz_message_email、biz_message_sms |
| 冷热数据分表 | biz_order、biz_order_history |
| 日志分表 | sys_oper_log_202605 |
| 大流水分表 | biz_pay_record_202605 |
| 审计分表 | sys_audit_log_2026 |
适合使用动态表名的表一般具有以下特点:
| 特点 | 说明 |
|---|---|
| 表结构一致 | 各分表字段、索引、约束基本一致 |
| 数据量大 | 单表数据持续增长,影响查询和维护 |
| 查询带分表条件 | 查询通常能明确年月、租户、业务类型 |
| 跨分表查询少 | 不频繁做全局分页和全局排序 |
| 生命周期清晰 | 可按时间归档、清理或迁移 |
| 业务边界明确 | 不需要频繁跨分表更新 |
不适合使用动态表名的场景:
| 场景 | 原因 |
|---|---|
| 经常跨所有分表分页 | 分页总数和排序复杂 |
| 经常跨所有分表聚合 | SQL 复杂且性能不可控 |
| 分表规则经常变化 | 路由规则维护成本高 |
| 表结构不一致 | 动态表名要求逻辑结构稳定 |
| 强依赖唯一约束全局唯一 | 单库分表下唯一约束通常只在单表内生效 |
| 简单小表 | 没有必要分表 |
动态表名是轻量分表能力,不等同于 ShardingSphere 这类完整分库分表中间件。如果需要跨库路由、分布式主键、跨分片聚合、跨分片事务,应评估更完整的分片方案。
DynamicTableNameInnerInterceptor 配置
DynamicTableNameInnerInterceptor 通过表名处理器返回真实表名。官方插件体系中,DynamicTableNameInnerInterceptor 属于会改写 SQL 的插件;多个插件同时使用时,官方建议多租户、动态表名这类 SQL 改写插件优先,分页和乐观锁随后,SQL 规范和防全表更新类插件最后。(MyBatis-Plus)
动态表名上下文如下。
文件位置:src/main/java/io/github/atengk/framework/dynamic/DynamicTableContext.java
package io.github.atengk.framework.dynamic;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
/**
* 动态表名上下文
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
public final class DynamicTableContext {
private static final ThreadLocal<String> TABLE_SUFFIX_HOLDER = new ThreadLocal<>();
private DynamicTableContext() {
}
/**
* 设置表名后缀
*
* @param tableSuffix 表名后缀
*/
public static void setTableSuffix(String tableSuffix) {
TABLE_SUFFIX_HOLDER.set(tableSuffix);
log.trace("设置动态表名后缀:{}", tableSuffix);
}
/**
* 获取表名后缀
*
* @return 表名后缀
*/
public static String getTableSuffix() {
return TABLE_SUFFIX_HOLDER.get();
}
/**
* 判断是否存在表名后缀
*
* @return 是否存在
*/
public static boolean hasTableSuffix() {
return StrUtil.isNotBlank(TABLE_SUFFIX_HOLDER.get());
}
/**
* 清理表名后缀
*/
public static void clear() {
TABLE_SUFFIX_HOLDER.remove();
log.trace("清理动态表名上下文");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
动态表名配置类如下。
文件位置:src/main/java/io/github/atengk/framework/mybatis/MyBatisPlusConfig.java
package io.github.atengk.framework.mybatis;
import cn.hutool.core.util.ReUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import io.github.atengk.framework.dynamic.DynamicTableContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Set;
/**
* MyBatis-Plus 配置
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Configuration
public class MyBatisPlusConfig {
private static final Set<String> DYNAMIC_TABLES = Set.of(
"biz_order",
"sys_oper_log",
"biz_pay_record"
);
/**
* 配置 MyBatis-Plus 拦截器
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 动态表名插件:根据上下文表后缀替换真实表名
DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor = new DynamicTableNameInnerInterceptor();
dynamicTableNameInnerInterceptor.setTableNameHandler((sql, tableName) -> {
if (!DYNAMIC_TABLES.contains(tableName)) {
return tableName;
}
String tableSuffix = DynamicTableContext.getTableSuffix();
if (StrUtil.isBlank(tableSuffix)) {
return tableName;
}
if (!ReUtil.isMatch("^[a-zA-Z0-9_]+$", tableSuffix)) {
throw new IllegalArgumentException("动态表名后缀不合法");
}
String realTableName = tableName + "_" + tableSuffix;
log.debug("动态表名替换完成,逻辑表:{},真实表:{}", tableName, realTableName);
return realTableName;
});
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
// 乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
// 分页插件
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
log.info("MyBatis-Plus插件初始化完成,已启用动态表名、乐观锁、分页插件");
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
动态表名配置建议:
| 配置项 | 建议 |
|---|---|
| 动态表白名单 | 必须维护,只允许指定逻辑表动态替换 |
| 后缀来源 | 必须来自后端计算或可信上下文 |
| 后缀校验 | 必须做正则校验 |
| 插件顺序 | 动态表名放在分页插件之前 |
| 日志 | 开发环境打印逻辑表和真实表 |
| 默认行为 | 无上下文时返回原表名 |
| 安全要求 | 禁止直接使用前端传入完整表名 |
不要让前端传完整表名,例如 biz_order_202605。前端最多传业务查询条件,例如时间,后端根据时间计算表后缀。
分表字段设计
分表字段是路由到具体物理表的依据。常见字段包括 create_time、order_time、tenant_id、biz_type、log_time 等。分表字段应在业务查询中稳定存在,否则容易出现无法定位分表的问题。
常见分表字段如下:
| 分表维度 | 字段 | 示例 |
|---|---|---|
| 按年月 | create_time、order_time | 202605 |
| 按租户 | tenant_id | tenant_1001 |
| 按业务类型 | biz_type | email、sms |
| 按年份 | create_time | 2026 |
| 按日志时间 | log_time | 202605 |
| 按区域 | region_code | cn_south |
订单表按年月分表示例:
CREATE TABLE biz_order_202605 (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
order_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '订单号',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
pay_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '支付金额',
order_status TINYINT NOT NULL DEFAULT 10 COMMENT '订单状态:10待支付,20已支付,90已取消',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_order_no (tenant_id, order_no),
KEY idx_tenant_time (tenant_id, create_time),
KEY idx_user_time (user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表_202605';2
3
4
5
6
7
8
9
10
11
12
13
14
15
分表字段设计建议:
| 规范项 | 建议 |
|---|---|
| 路由字段 | 新增、查询、修改时尽量都能获得 |
| 时间分表 | 使用业务发生时间,不一定使用创建时间 |
| 租户分表 | 租户 ID 必须可信 |
| 业务类型分表 | 业务类型编码必须稳定 |
| 索引 | 分表字段通常参与组合索引 |
| 唯一约束 | 注意唯一约束只在单表内生效 |
| 修改限制 | 分表字段尽量不可修改 |
如果分表字段允许修改,数据可能需要跨表迁移,复杂度会显著增加。通常应将分表字段设计为不可变字段。
按年月分表
按年月分表是最常见的动态表名场景,适合订单、支付流水、操作日志、审计日志、消息记录等按时间持续增长的数据。物理表命名通常为 逻辑表_yyyyMM。
表名示例:
biz_order_202601
biz_order_202602
biz_order_2026032
3
时间工具类如下。
文件位置:src/main/java/io/github/atengk/framework/dynamic/DynamicTableSuffixUtil.java
package io.github.atengk.framework.dynamic;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 动态表名后缀工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class DynamicTableSuffixUtil {
private DynamicTableSuffixUtil() {
}
/**
* 根据日期时间获取年月后缀
*
* @param dateTime 日期时间
* @return 年月后缀,例如 202605
*/
public static String getYearMonthSuffix(LocalDateTime dateTime) {
return LocalDateTimeUtil.format(dateTime, DatePattern.SIMPLE_MONTH_PATTERN);
}
/**
* 根据日期获取年月后缀
*
* @param date 日期
* @return 年月后缀,例如 202605
*/
public static String getYearMonthSuffix(LocalDate date) {
return LocalDateTimeUtil.format(date.atStartOfDay(), DatePattern.SIMPLE_MONTH_PATTERN);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
新增订单时,根据订单创建时间设置动态表名上下文。
文件位置:src/main/java/io/github/atengk/module/order/service/impl/OrderDynamicTableService.java
package io.github.atengk.module.order.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.framework.dynamic.DynamicTableContext;
import io.github.atengk.framework.dynamic.DynamicTableSuffixUtil;
import io.github.atengk.module.order.entity.OrderEntity;
import io.github.atengk.module.order.mapper.OrderMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 订单动态表服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class OrderDynamicTableService extends ServiceImpl<OrderMapper, OrderEntity> {
/**
* 按年月分表新增订单
*
* @param order 订单实体
* @return 订单 ID
*/
@Transactional(rollbackFor = Exception.class)
public Long addOrderByMonth(OrderEntity order) {
LocalDateTime orderTime = order.getCreateTime() == null ? LocalDateTime.now() : order.getCreateTime();
String tableSuffix = DynamicTableSuffixUtil.getYearMonthSuffix(orderTime);
try {
DynamicTableContext.setTableSuffix(tableSuffix);
this.save(order);
log.info("按年月分表新增订单成功,订单ID:{},表后缀:{}", order.getId(), tableSuffix);
return order.getId();
} finally {
DynamicTableContext.clear();
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
按月份查询订单:
public List<OrderEntity> listOrderByMonth(LocalDateTime monthTime) {
String tableSuffix = DynamicTableSuffixUtil.getYearMonthSuffix(monthTime);
try {
DynamicTableContext.setTableSuffix(tableSuffix);
return this.lambdaQuery()
.orderByDesc(OrderEntity::getCreateTime)
.list();
} finally {
DynamicTableContext.clear();
}
}2
3
4
5
6
7
8
9
10
11
12
按年月分表注意事项:
| 场景 | 建议 |
|---|---|
| 单月查询 | 设置单个月份后缀,直接查对应表 |
| 跨月查询 | 拆成多次查询后合并,或使用 UNION |
| 跨月分页 | 谨慎,通常不建议直接做全局分页 |
| 新增数据 | 根据业务时间确定表后缀 |
| 修改数据 | 必须能定位原始月份 |
| 删除数据 | 必须带分表字段定位 |
| 导出数据 | 按月份拆分执行 |
跨月查询不是动态表名插件自动解决的。插件一次 SQL 通常只会把逻辑表替换成一个真实表名;跨月场景需要 Service 层拆分月份,多次查询后合并结果。
按租户分表
按租户分表适合租户数据量差异大、租户隔离要求高、单租户数据生命周期独立的场景。表名可以设计为 biz_order_tenant_1001,也可以按哈希桶设计为 biz_order_00、biz_order_01,避免租户数量过多导致表数量失控。
直接按租户 ID 分表示例:
biz_order_tenant_1001
biz_order_tenant_10022
按租户哈希桶分表示例:
biz_order_00
biz_order_01
biz_order_02
...
biz_order_152
3
4
5
推荐优先考虑哈希桶,而不是每个租户一张表。每租户一张表在租户数量多时会显著增加建表、迁移、统计、监控和运维成本。
租户分表后缀工具:
文件位置:src/main/java/io/github/atengk/framework/dynamic/TenantTableSuffixUtil.java
package io.github.atengk.framework.dynamic;
import cn.hutool.core.util.NumberUtil;
/**
* 租户分表后缀工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class TenantTableSuffixUtil {
private static final int TABLE_BUCKET_SIZE = 16;
private TenantTableSuffixUtil() {
}
/**
* 根据租户 ID 获取分表后缀
*
* @param tenantId 租户 ID
* @return 分表后缀,例如 00、01、15
*/
public static String getTenantBucketSuffix(Long tenantId) {
if (tenantId == null || tenantId <= 0) {
throw new IllegalArgumentException("租户ID不能为空");
}
int bucket = NumberUtil.toBigInteger(tenantId).mod(NumberUtil.toBigInteger(TABLE_BUCKET_SIZE)).intValue();
return String.format("%02d", bucket);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
按租户分表查询:
public List<OrderEntity> listOrderByTenant(Long tenantId) {
String tableSuffix = TenantTableSuffixUtil.getTenantBucketSuffix(tenantId);
try {
DynamicTableContext.setTableSuffix(tableSuffix);
return this.lambdaQuery()
.eq(OrderEntity::getTenantId, tenantId)
.orderByDesc(OrderEntity::getCreateTime)
.list();
} finally {
DynamicTableContext.clear();
}
}2
3
4
5
6
7
8
9
10
11
12
13
按租户分表建议:
| 方案 | 建议 |
|---|---|
| 每租户一表 | 租户少且隔离要求高时可用 |
| 租户哈希分表 | 租户多时更推荐 |
| 租户字段 | 表内仍建议保留 tenant_id |
| 唯一索引 | 仍建议带 tenant_id |
| 跨租户统计 | 单独做报表或汇总表 |
| 租户迁移 | 每租户一表更易迁移,但表数量多 |
| 租户扩容 | 哈希桶扩容较复杂,需要提前规划桶数 |
即使按租户分表,表内也建议保留 tenant_id。这样便于数据校验、审计、迁移、统计和防止误路由。
按业务类型分表
按业务类型分表适合消息、通知、任务、附件、日志等“同一逻辑模型下存在多个业务类型”的数据。例如消息表可以按邮件、短信、站内信分表。
表名示例:
biz_message_email
biz_message_sms
biz_message_site2
3
业务类型枚举:
文件位置:src/main/java/io/github/atengk/module/message/enums/MessageTypeEnum.java
package io.github.atengk.module.message.enums;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 消息类型枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum MessageTypeEnum {
/**
* 邮件
*/
EMAIL("email", "邮件"),
/**
* 短信
*/
SMS("sms", "短信"),
/**
* 站内信
*/
SITE("site", "站内信");
/**
* 类型编码
*/
private final String code;
/**
* 类型名称
*/
private final String name;
/**
* 根据编码获取枚举
*
* @param code 类型编码
* @return 消息类型
*/
public static MessageTypeEnum ofCode(String code) {
return Arrays.stream(values())
.filter(item -> ObjectUtil.equals(item.getCode(), code))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("消息类型不存在:" + code));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
业务类型分表使用:
public Long addMessage(MessageEntity entity, String messageType) {
MessageTypeEnum typeEnum = MessageTypeEnum.ofCode(messageType);
try {
DynamicTableContext.setTableSuffix(typeEnum.getCode());
this.save(entity);
log.info("按业务类型分表新增消息成功,消息ID:{},类型:{}", entity.getId(), typeEnum.getCode());
return entity.getId();
} finally {
DynamicTableContext.clear();
}
}2
3
4
5
6
7
8
9
10
11
12
按业务类型分表建议:
| 场景 | 建议 |
|---|---|
| 类型稳定 | 适合分表 |
| 类型频繁新增 | 谨慎,建表和发布成本高 |
| 类型字段 | 表内仍建议保留 biz_type |
| 查询类型明确 | 适合动态表名 |
| 跨类型查询 | 不建议频繁发生 |
| 统计报表 | 可以使用汇总表 |
业务类型分表要求类型编码稳定。不要把运营可随意新增的分类直接作为物理表后缀。
动态表名上下文
动态表名上下文用于在当前线程内传递表后缀。使用 ThreadLocal 时必须遵循“设置后使用,使用后清理”的原则。否则线程池复用时可能导致后续请求误用上一个请求的表后缀。
推荐封装一个执行工具,避免业务代码忘记清理上下文。
文件位置:src/main/java/io/github/atengk/framework/dynamic/DynamicTableExecutor.java
package io.github.atengk.framework.dynamic;
import lombok.extern.slf4j.Slf4j;
import java.util.function.Supplier;
/**
* 动态表名执行器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
public final class DynamicTableExecutor {
private DynamicTableExecutor() {
}
/**
* 在指定动态表后缀上下文中执行操作
*
* @param tableSuffix 表名后缀
* @param supplier 执行逻辑
* @param <T> 返回类型
* @return 执行结果
*/
public static <T> T execute(String tableSuffix, Supplier<T> supplier) {
try {
DynamicTableContext.setTableSuffix(tableSuffix);
return supplier.get();
} finally {
DynamicTableContext.clear();
}
}
/**
* 在指定动态表后缀上下文中执行操作
*
* @param tableSuffix 表名后缀
* @param runnable 执行逻辑
*/
public static void execute(String tableSuffix, Runnable runnable) {
try {
DynamicTableContext.setTableSuffix(tableSuffix);
runnable.run();
} finally {
DynamicTableContext.clear();
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
使用示例:
public OrderEntity getOrderByMonth(Long orderId, LocalDateTime orderTime) {
String tableSuffix = DynamicTableSuffixUtil.getYearMonthSuffix(orderTime);
return DynamicTableExecutor.execute(tableSuffix, () -> this.getById(orderId));
}2
3
4
5
跨月查询示例:
public List<OrderEntity> listOrderBetweenMonth(List<String> monthSuffixList) {
if (CollUtil.isEmpty(monthSuffixList)) {
return List.of();
}
return monthSuffixList.stream()
.flatMap(monthSuffix -> DynamicTableExecutor
.execute(monthSuffix, () -> this.lambdaQuery()
.orderByDesc(OrderEntity::getCreateTime)
.list())
.stream())
.toList();
}2
3
4
5
6
7
8
9
10
11
12
13
动态表名上下文注意事项:
| 场景 | 建议 |
|---|---|
| Web 请求 | 不建议把表后缀长期放在拦截器中 |
| Service 方法 | 推荐在业务方法内设置和清理 |
| 异步任务 | 必须显式传递表后缀 |
| 定时任务 | 每次处理前设置,处理后清理 |
| MQ 消费 | 从消息中解析后缀,消费后清理 |
| 线程池 | 不清理会导致上下文污染 |
| 跨表查询 | 不要指望一个上下文处理多个物理表 |
表后缀不应是全局状态,也不应跨业务方法长期存在。最稳妥方式是在最小必要范围内设置。
动态表名与 SQL 缓存
动态表名插件在 SQL 执行前替换表名。由于逻辑 SQL 相同但真实表名不同,开发时需要关注 MyBatis 一级缓存、二级缓存、SQL 解析缓存和业务缓存的边界。官方文档说明动态表名插件会在执行 SQL 前按规则替换表名;同时官方注意事项也强调动态表名规则要避免误替换和安全问题。(MyBatis-Plus)
需要重点关注以下问题:
| 问题 | 说明 |
|---|---|
| MyBatis 一级缓存 | 同一个 SqlSession 内可能缓存查询结果 |
| MyBatis 二级缓存 | 不建议在动态表名场景开启 |
| 业务缓存 Key | 必须包含真实表后缀或分表字段 |
| SQL 日志 | 开发环境要确认最终 SQL 表名正确 |
| SQL 解析缓存 | 表名处理规则必须稳定,不要依赖随机值 |
| 分页 COUNT | 确认 COUNT SQL 使用正确真实表 |
| 跨表查询 | 不要混用同一个缓存 Key |
不推荐在动态表名 Mapper 上开启二级缓存:
<!-- 不建议在动态表名场景中启用二级缓存 -->
<!-- <cache/> -->2
业务缓存 Key 示例:
String cacheKey = StrUtil.format("order:{}:{}", tableSuffix, orderId);错误缓存 Key:
String cacheKey = StrUtil.format("order:{}", orderId);如果只用 orderId 作为缓存 Key,不包含表后缀,当不同分表中存在相同业务 ID 或查询语义依赖分表时,可能出现缓存串数据。
动态表名与缓存建议:
| 规范项 | 建议 |
|---|---|
| MyBatis 二级缓存 | 不建议开启 |
| 业务缓存 | Key 必须包含分表字段或真实表后缀 |
| SQL 日志 | 开发和测试环境开启 |
| 表名规则 | 必须确定性,禁止随机 |
| 查询上下文 | 每次查询明确设置后缀 |
| 跨表结果 | 合并后单独缓存,缓存 Key 包含查询范围 |
动态表名处理器中不要使用随机数决定表名。官方示例中使用随机数仅用于演示,生产环境必须使用确定性的业务规则,例如年月、租户 ID、业务类型。(MyBatis-Plus)
动态表名测试
动态表名测试必须覆盖表名替换、上下文清理、分页查询、插入、修改、删除、跨月查询和非法后缀。只测试新增是不够的。
测试建表示例:
CREATE TABLE biz_order_202605 LIKE biz_order;
CREATE TABLE biz_order_202606 LIKE biz_order;2
测试类示例:
文件位置:src/test/java/io/github/atengk/module/order/DynamicTableNameTest.java
package io.github.atengk.module.order;
import io.github.atengk.framework.dynamic.DynamicTableExecutor;
import io.github.atengk.module.order.entity.OrderEntity;
import io.github.atengk.module.order.service.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.math.BigDecimal;
import java.util.List;
/**
* 动态表名测试
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@SpringBootTest
@ActiveProfiles("test")
@MapperScan("io.github.atengk.**.mapper")
class DynamicTableNameTest {
@Autowired
private OrderService orderService;
/**
* 测试按月份动态表新增和查询
*/
@Test
void testInsertAndSelectByMonthTable() {
OrderEntity order = new OrderEntity();
order.setTenantId(1001L);
order.setOrderNo("DYNAMIC_202605_001");
order.setUserId(2001L);
order.setPayAmount(new BigDecimal("100.00"));
order.setOrderStatus(20);
Long orderId = DynamicTableExecutor.execute("202605", () -> {
orderService.save(order);
return order.getId();
});
OrderEntity savedOrder = DynamicTableExecutor.execute("202605", () -> orderService.getById(orderId));
Assertions.assertNotNull(savedOrder);
Assertions.assertEquals("DYNAMIC_202605_001", savedOrder.getOrderNo());
log.info("动态表新增和查询测试完成,订单ID:{},表后缀:{}", orderId, "202605");
}
/**
* 测试不同月份动态表数据隔离
*/
@Test
void testDifferentMonthTableIsolation() {
OrderEntity order = new OrderEntity();
order.setTenantId(1001L);
order.setOrderNo("DYNAMIC_ISOLATION_001");
order.setUserId(2001L);
order.setPayAmount(new BigDecimal("200.00"));
order.setOrderStatus(20);
Long orderId = DynamicTableExecutor.execute("202605", () -> {
orderService.save(order);
return order.getId();
});
OrderEntity mayOrder = DynamicTableExecutor.execute("202605", () -> orderService.getById(orderId));
OrderEntity juneOrder = DynamicTableExecutor.execute("202606", () -> orderService.getById(orderId));
Assertions.assertNotNull(mayOrder);
Assertions.assertNull(juneOrder);
log.info("动态表数据隔离测试完成,订单ID:{}", orderId);
}
/**
* 测试分页查询动态表
*/
@Test
void testPageByDynamicTable() {
List<OrderEntity> records = DynamicTableExecutor.execute("202605", () -> orderService.lambdaQuery()
.eq(OrderEntity::getTenantId, 1001L)
.orderByDesc(OrderEntity::getCreateTime)
.last("LIMIT 10")
.list());
Assertions.assertNotNull(records);
log.info("动态表分页查询测试完成,数量:{}", records.size());
}
/**
* 测试非法表后缀
*/
@Test
void testInvalidTableSuffix() {
Assertions.assertThrows(IllegalArgumentException.class, () ->
DynamicTableExecutor.execute("202605;DROP_TABLE", () -> orderService.list())
);
log.info("非法动态表后缀测试完成");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
动态表名测试重点:
| 测试项 | 验证内容 |
|---|---|
| 新增 | 数据是否写入正确物理表 |
| 查询 | 是否查询正确物理表 |
| 修改 | 是否只修改目标分表数据 |
| 删除 | 是否只删除目标分表数据 |
| 分页 | COUNT 和列表是否使用同一真实表 |
| 非法后缀 | 是否被拦截 |
| 上下文清理 | 一个测试结束后不影响下一个测试 |
| 跨表查询 | Service 拆分后结果是否正确 |
| 缓存 | 缓存 Key 是否包含分表信息 |
| SQL 日志 | 最终 SQL 是否替换为真实表名 |
开发和测试环境建议开启 Mapper SQL 日志,确认最终执行 SQL 中的表名是否符合预期:
logging:
level:
io.github.atengk.**.mapper: debug2
3
动态表名落地建议:
| 规范项 | 建议 |
|---|---|
| 表名规则 | 统一使用工具类生成 |
| 后缀来源 | 后端根据业务字段计算 |
| 物理表管理 | 建表脚本、迁移脚本、归档策略必须明确 |
| 跨表查询 | Service 层拆分,不要强行依赖插件 |
| 插件顺序 | 动态表名优先于分页插件 |
| 上下文 | 使用后必须清理 |
| 缓存 | Key 中包含分表维度 |
| 测试 | 必须覆盖不同分表之间的数据隔离 |
动态表名适合“路由到单个明确物理表”的场景。只要查询范围跨多个物理表,就应在 Service 层显式拆分分表范围、分别查询、再进行合并或汇总。
SQL 性能优化
SQL 性能优化的目标不是盲目追求“SQL 越短越好”,而是让查询条件、索引、数据量、分页方式、事务范围、连接池配置和业务访问模式匹配。MyBatis-Plus 已经简化了 CRUD 和条件构造,但不会自动解决慢 SQL、索引失效、N+1 查询、大事务、深分页、COUNT 慢和连接池耗尽等问题。本章节建议作为开发规范和代码评审清单使用。
SQL 执行日志
SQL 执行日志用于观察最终执行的 SQL、参数、耗时和返回结果数量。开发环境建议开启 SQL 日志;测试环境可以按需开启;生产环境不建议长期打印完整 SQL,尤其是包含敏感参数的大量业务 SQL。
MyBatis-Plus 官方提供了基于 p6spy 的 SQL 分析与打印方案,可以输出 SQL 语句及其执行时间,也支持通过 outagedetection 和 outagedetectioninterval 识别慢 SQL;官方也明确提醒该能力有性能损耗,不建议在生产环境长期使用。(MyBatis-Plus)
开发环境配置示例:
spring:
datasource:
driver-class-name: com.p6spy.engine.spy.P6SpyDriver
url: jdbc:p6spy:mysql://127.0.0.1:3306/mybatis_plus_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
logging:
level:
io.github.atengk.**.mapper: debug2
3
4
5
6
7
8
9
10
spy.properties 示例:
modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
deregisterdrivers=true
useprefix=true
excludecategories=info,debug,result,commit,resultset
dateformat=yyyy-MM-dd HH:mm:ss
outagedetection=true
outagedetectioninterval=22
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SQL 日志建议:
| 环境 | 建议 |
|---|---|
| 本地开发 | 可以开启完整 SQL |
| 测试环境 | 按问题排查临时开启 |
| 预发环境 | 只开启慢 SQL 或采样日志 |
| 生产环境 | 不长期打印完整 SQL |
| 敏感字段 | 密码、Token、密钥、身份证号不能明文记录 |
| 批量 SQL | 避免日志刷屏,必要时采样 |
SQL 日志不是性能优化的最终依据。它只能帮助定位可疑 SQL,最终还需要结合执行计划、索引、数据量和数据库监控确认问题。
SQL 执行计划
执行计划用于分析数据库如何执行 SQL,例如是否走索引、扫描多少行、使用什么连接方式、是否产生临时表、是否文件排序。MySQL 中通常使用 EXPLAIN 或 EXPLAIN ANALYZE 分析 SQL。
示例:
EXPLAIN
SELECT
id,
username,
phone,
status,
create_time
FROM sys_user
WHERE tenant_id = 1001
AND deleted = 0
AND username LIKE 'admin%'
ORDER BY create_time DESC
LIMIT 10;2
3
4
5
6
7
8
9
10
11
12
13
重点关注字段:
| 字段 | 说明 |
|---|---|
type | 访问类型,常见有 const、ref、range、index、ALL |
possible_keys | 可能使用的索引 |
key | 实际使用的索引 |
rows | 预估扫描行数 |
filtered | 过滤比例 |
Extra | 额外信息,例如 Using where、Using filesort、Using temporary |
常见问题判断:
| 现象 | 可能原因 |
|---|---|
type = ALL | 全表扫描 |
key = NULL | 没有使用索引 |
rows 很大 | 扫描行过多 |
Using filesort | 排序未利用索引 |
Using temporary | 分组、排序或去重产生临时表 |
Using index | 覆盖索引,通常较好 |
Using index condition | 使用了索引条件下推 |
执行计划分析建议:
| 场景 | 建议 |
|---|---|
| 慢 SQL | 必须看执行计划 |
| 新增索引前 | 先看当前执行计划 |
| 新增索引后 | 再看是否命中索引 |
| 分页查询 | 重点看排序字段和扫描行数 |
| JOIN 查询 | 重点看驱动表和关联字段索引 |
| 统计查询 | 重点看分组字段和过滤条件 |
| 线上问题 | 使用真实参数和接近真实数据量分析 |
不要只在空表或小数据表上看执行计划。小数据量时数据库可能选择全表扫描,但大数据量时问题才会暴露。
慢 SQL 识别
慢 SQL 是指执行时间超过业务阈值的 SQL。不同系统阈值不同,后台管理系统常见阈值可以从 500ms 或 1s 开始,核心链路可以更严格。识别慢 SQL 应结合应用日志、数据库慢查询日志、APM、连接池指标和业务接口耗时。
常见慢 SQL 来源:
| 类型 | 示例 |
|---|---|
| 无索引查询 | WHERE phone = ? 但 phone 无索引 |
| 索引失效 | 对索引字段使用函数或隐式转换 |
| 大范围扫描 | LIKE '%keyword%' |
| 深分页 | LIMIT 1000000, 20 |
| COUNT 慢 | 大表复杂条件统计 |
| N+1 查询 | 循环中逐条查询 |
| 大字段读取 | 列表页查询 content、json_data |
| JOIN 过多 | 多表关联且关联字段无索引 |
| 大事务 | 锁持有时间长导致等待 |
推荐建立慢 SQL 记录对象,用于应用层采集关键接口中的慢 SQL 或慢查询上下文。
文件位置:src/main/java/io/github/atengk/framework/sql/SlowSqlRecord.java
package io.github.atengk.framework.sql;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 慢 SQL 记录对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class SlowSqlRecord {
/**
* Mapper 方法
*/
private String mapperMethod;
/**
* SQL 摘要
*/
private String sqlSummary;
/**
* 执行耗时,单位毫秒
*/
private Long costMillis;
/**
* 记录时间
*/
private LocalDateTime recordTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
慢 SQL 治理建议:
| 阶段 | 动作 |
|---|---|
| 发现 | 日志、慢查询日志、APM、监控报警 |
| 定位 | 找到 Mapper 方法、SQL、参数、调用接口 |
| 分析 | 执行计划、索引、数据量、锁等待 |
| 优化 | 改 SQL、加索引、拆查询、改分页 |
| 验证 | 对比优化前后耗时和执行计划 |
| 防复发 | 加代码评审规则和监控阈值 |
慢 SQL 不应只靠数据库管理员兜底。业务开发需要在开发和测试阶段就能识别常见低效 SQL。
索引命中分析
索引命中分析用于确认查询条件和排序是否利用了合适索引。索引不是越多越好,过多索引会增加写入成本、占用存储空间,也可能干扰优化器选择。
常见索引设计示例:
CREATE INDEX idx_tenant_status_time
ON sys_user (tenant_id, status, create_time);
CREATE INDEX idx_tenant_phone
ON sys_user (tenant_id, phone);
CREATE UNIQUE INDEX uk_tenant_username
ON sys_user (tenant_id, username);2
3
4
5
6
7
8
适合索引的字段:
| 字段类型 | 示例 |
|---|---|
| 高频等值查询 | tenant_id、user_id、order_no |
| 高频范围查询 | create_time、pay_time |
| 高频排序字段 | create_time、sort_order |
| 唯一约束字段 | order_no、username |
| JOIN 关联字段 | user_id、dept_id、role_id |
| 组合查询字段 | tenant_id + status + create_time |
容易导致索引失效的写法:
-- 对索引字段使用函数
WHERE DATE(create_time) = '2026-05-05'
-- 左模糊
WHERE username LIKE '%admin'
-- 隐式类型转换
WHERE phone = 13800000000
-- 不符合联合索引最左前缀
WHERE status = 1
ORDER BY create_time DESC2
3
4
5
6
7
8
9
10
11
12
推荐改写:
-- 用范围替代函数
WHERE create_time >= '2026-05-05 00:00:00'
AND create_time < '2026-05-06 00:00:00'
-- 尽量使用右模糊
WHERE username LIKE 'admin%'
-- 字段类型和参数类型一致
WHERE phone = '13800000000'
-- 组合索引匹配查询条件
WHERE tenant_id = 1001
AND status = 1
ORDER BY create_time DESC2
3
4
5
6
7
8
9
10
11
12
13
14
索引命中建议:
| 规范项 | 建议 |
|---|---|
| 联合索引 | 高频查询条件靠前 |
| 等值字段 | 一般放范围字段前 |
| 范围字段 | 一般放在联合索引后部 |
| 排序字段 | 尽量和查询条件组成联合索引 |
| 低区分度字段 | 不单独建索引,结合其他字段 |
| 大字段 | 不建普通索引 |
| 写多读少表 | 控制索引数量 |
| 唯一字段 | 用唯一索引兜底并发一致性 |
索引优化必须结合具体 SQL 和数据分布。不要脱离查询场景盲目建索引。
避免 SELECT 星号
SELECT * 会返回表中所有字段,容易带来不必要的 IO、网络传输、对象映射成本,也可能把敏感字段或大字段返回给业务层。列表接口、分页接口、导出接口都应明确字段。
不推荐:
<select id="selectUserPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT *
FROM sys_user
WHERE deleted = 0
ORDER BY create_time DESC
</select>2
3
4
5
6
推荐:
<select id="selectUserPage" resultType="io.github.atengk.module.system.user.vo.UserPageVO">
SELECT
id,
username,
nickname,
phone,
status,
create_time
FROM sys_user
WHERE deleted = 0
ORDER BY create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
Wrapper 查询指定字段:
List<UserEntity> users = this.lambdaQuery()
.select(
UserEntity::getId,
UserEntity::getUsername,
UserEntity::getNickname,
UserEntity::getPhone,
UserEntity::getStatus,
UserEntity::getCreateTime
)
.eq(UserEntity::getStatus, 1)
.orderByDesc(UserEntity::getCreateTime)
.list();2
3
4
5
6
7
8
9
10
11
12
避免 SELECT * 的收益:
| 收益 | 说明 |
|---|---|
| 减少 IO | 不读取无用字段 |
| 减少网络传输 | 返回数据更小 |
| 减少反序列化成本 | Java 对象映射更轻 |
| 避免敏感泄露 | 密码、密钥不被误查 |
| 利用覆盖索引 | 查询字段都在索引中时性能更好 |
| 降低变更影响 | 新增字段不会意外返回 |
实体查询可以在内部逻辑中使用完整字段,但接口返回建议通过 VO 控制字段。
避免大事务
大事务会长时间持有锁、占用连接、增加回滚成本,并可能造成主从延迟、undo 日志膨胀和其他请求阻塞。批量导入、批量修改、批量删除、复杂多表操作尤其容易产生大事务。
大事务常见来源:
| 来源 | 示例 |
|---|---|
| 一次导入几十万条 | 一个事务包住所有数据 |
| 事务中调用外部接口 | HTTP 请求阻塞事务 |
| 事务中处理文件 | 解析大 Excel 后再写库 |
| 事务中执行复杂统计 | 查询长时间占用连接 |
| 批量删除不分批 | 一次删除大量数据 |
| 人工审核长流程 | 锁住数据等待人工操作 |
错误示例:
@Transactional(rollbackFor = Exception.class)
public void importLargeFile(MultipartFile file) {
List<UserAddDTO> dtoList = parseLargeExcel(file);
for (UserAddDTO dto : dtoList) {
userService.addUser(dto);
}
}2
3
4
5
6
7
推荐:先解析文件,再分批事务写入。
public void importLargeFile(MultipartFile file) {
List<UserAddDTO> dtoList = parseLargeExcel(file);
List<List<UserAddDTO>> partitions = CollUtil.split(dtoList, 1000);
for (List<UserAddDTO> partition : partitions) {
userImportTransactionService.saveBatchInNewTransaction(partition);
}
log.info("用户文件导入完成,总数:{},批次数:{}", dtoList.size(), partitions.size());
}2
3
4
5
6
7
8
9
10
分批事务服务:
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.module.system.user.convert.UserConvert;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 用户导入事务服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserImportTransactionService extends ServiceImpl<UserMapper, UserEntity> {
private final UserConvert userConvert;
/**
* 使用独立事务保存一批用户
*
* @param dtoList 用户新增参数列表
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void saveBatchInNewTransaction(List<UserAddDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return;
}
List<UserEntity> users = dtoList.stream()
.map(userConvert::toEntity)
.toList();
this.saveBatch(users, 1000);
log.info("批量保存用户成功,数量:{}", users.size());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
避免大事务建议:
| 场景 | 建议 |
|---|---|
| 文件解析 | 放在事务外 |
| 外部接口调用 | 放在事务外或事务提交后 |
| 批量写入 | 分批事务 |
| 批量删除 | 分批删除 |
| 大量校验 | 事务前先校验 |
| 导入失败 | 记录失败明细,不一定全部回滚 |
| 事务时间 | 尽量控制在短时间内 |
| 连接占用 | 不要在事务中等待用户或远程系统 |
避免循环查询
循环查询是最常见的性能问题之一。典型表现是先查询列表,再在循环中逐条查询关联数据,形成 N+1 查询。
错误示例:
public List<UserPageVO> listUserWithRoleNames() {
List<UserEntity> users = this.list();
return users.stream()
.map(user -> {
UserPageVO vo = userConvert.toPageVO(user);
List<String> roleNames = roleMapper.selectRoleNamesByUserId(user.getId());
vo.setRoleNames(roleNames);
return vo;
})
.toList();
}2
3
4
5
6
7
8
9
10
11
12
如果用户数量是 100,就会执行 1 次用户查询 + 100 次角色查询。
推荐:批量查询关联数据后在内存中分组。
public List<UserPageVO> listUserWithRoleNames() {
List<UserEntity> users = this.list();
if (CollUtil.isEmpty(users)) {
return List.of();
}
List<Long> userIds = users.stream()
.map(UserEntity::getId)
.toList();
List<UserRoleNameVO> roleNames = roleMapper.selectRoleNamesByUserIds(userIds);
Map<Long, List<String>> roleNameMap = roleNames.stream()
.collect(Collectors.groupingBy(
UserRoleNameVO::getUserId,
Collectors.mapping(UserRoleNameVO::getRoleName, Collectors.toList())
));
return users.stream()
.map(user -> {
UserPageVO vo = userConvert.toPageVO(user);
vo.setRoleNames(roleNameMap.getOrDefault(user.getId(), List.of()));
return vo;
})
.toList();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
VO 示例:
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
/**
* 用户角色名称返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserRoleNameVO {
/**
* 用户 ID
*/
private Long userId;
/**
* 角色名称
*/
private String roleName;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Mapper SQL:
<select id="selectRoleNamesByUserIds" resultType="io.github.atengk.module.system.user.vo.UserRoleNameVO">
SELECT
ur.user_id,
r.role_name
FROM sys_user_role ur
INNER JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0
WHERE ur.deleted = 0
AND ur.user_id IN
<foreach collection="userIds" item="userId" open="(" separator="," close=")">
#{userId}
</foreach>
ORDER BY ur.user_id ASC, r.sort_order ASC
</select>2
3
4
5
6
7
8
9
10
11
12
13
循环查询治理建议:
| 场景 | 处理方式 |
|---|---|
| 一对一关联 | JOIN 查询或批量查询 Map 组装 |
| 一对多关联 | 批量查询后分组 |
| 字典回显 | 批量加载字典或缓存 |
| 用户名称回显 | 批量查询用户 Map |
| 部门名称回显 | 缓存部门 Map |
| 角色列表 | 批量查询关系表 |
| 远程接口 | 批量接口或缓存 |
代码评审中应重点检查 for、stream().map()、forEach() 内是否存在数据库查询或远程调用。
批量查询优化
批量查询用于减少数据库往返次数。常见方式是使用 IN 查询、批量加载 Map、分批查询和缓存热点数据。
按 ID 批量查询:
public Map<Long, UserEntity> mapUserByIds(List<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
return Map.of();
}
List<UserEntity> users = this.listByIds(userIds);
if (CollUtil.isEmpty(users)) {
return Map.of();
}
return users.stream()
.collect(Collectors.toMap(UserEntity::getId, item -> item, (a, b) -> a));
}2
3
4
5
6
7
8
9
10
11
12
13
大集合分批查询:
public List<UserEntity> listUserByIdsInBatch(List<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
return List.of();
}
List<List<Long>> partitions = CollUtil.split(userIds, 1000);
return partitions.stream()
.flatMap(partition -> this.lambdaQuery()
.in(UserEntity::getId, partition)
.list()
.stream())
.toList();
}2
3
4
5
6
7
8
9
10
11
12
13
批量查询建议:
| 场景 | 建议 |
|---|---|
| ID 数量小 | 直接 listByIds |
| ID 数量大 | 分批 IN 查询 |
| 关联回显 | 批量查询后 Map 组装 |
| 字典数据 | 缓存 |
| 部门树 | 缓存或一次性加载 |
| 查询字段少 | 指定 select 字段 |
| 大列表 | 分页或游标查询 |
不要无限制使用 IN。大量 ID 查询应分批,常见批次大小为 500 到 2000,具体按数据库和 SQL 长度限制调整。
批量写入优化
批量写入包括批量新增、批量修改和批量删除。MyBatis-Plus 提供 saveBatch、updateBatchById、saveOrUpdateBatch 等方法。批量写入的核心是控制批次大小、事务范围、唯一约束、失败策略和连接占用。
批量新增:
@Transactional(rollbackFor = Exception.class)
public void batchAddUsers(List<UserAddDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return;
}
List<UserEntity> users = dtoList.stream()
.map(userConvert::toEntity)
.toList();
this.saveBatch(users, 1000);
log.info("批量新增用户完成,数量:{}", users.size());
}2
3
4
5
6
7
8
9
10
11
12
13
批量修改相同字段:
@Transactional(rollbackFor = Exception.class)
public void batchDisableUsers(List<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
return;
}
boolean updated = this.lambdaUpdate()
.in(UserEntity::getId, userIds)
.set(UserEntity::getStatus, 0)
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("批量禁用用户失败");
}
log.info("批量禁用用户完成,数量:{}", userIds.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
批量修改不同字段:
@Transactional(rollbackFor = Exception.class)
public void batchUpdateUsers(List<UserUpdateDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
return;
}
List<UserEntity> users = dtoList.stream()
.map(dto -> {
UserEntity entity = this.getById(dto.getId());
if (entity == null) {
throw new BusinessException("用户不存在:" + dto.getId());
}
userConvert.updateEntity(dto, entity);
return entity;
})
.toList();
this.updateBatchById(users, 1000);
log.info("批量修改用户完成,数量:{}", users.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
批量写入建议:
| 场景 | 建议 |
|---|---|
| 批量新增 | saveBatch(list, batchSize) |
| 批量修改不同值 | updateBatchById(list, batchSize) |
| 批量修改相同值 | lambdaUpdate().in().set() |
| 批量删除 | 分批 removeBatchByIds |
| 大批量导入 | 分批事务 |
| 失败明细 | 导入场景记录行号和原因 |
| 唯一约束 | 数据库唯一索引兜底 |
| 写入性能 | 控制索引数量和事务范围 |
批量写入不是 batchSize 越大越好。过大会导致 SQL 包过大、内存占用高、事务时间长。应基于压测确定批次大小。
分页查询优化
MyBatis-Plus 的 PaginationInnerInterceptor 提供分页能力,并支持 maxLimit、searchCount、optimizeCountSql、optimizeJoinOfCountSql、countId 等分页相关属性;从 3.5.9 起使用分页插件需要单独引入 mybatis-plus-jsqlparser。官方也提醒,带 left join 的 SQL 在优化 COUNT 时,如果关联表不参与 where 条件,可能被优化移除,因此 JOIN SQL 建议统一使用表别名和字段别名。(MyBatis-Plus)
分页查询基础优化:
| 优化点 | 建议 |
|---|---|
| 页大小 | 限制最大 pageSize |
| 查询字段 | 不使用 SELECT * |
| 排序字段 | 建索引或组合索引 |
| 查询条件 | 命中索引 |
| 大字段 | 列表页不返回 |
| COUNT | 非必要时关闭 |
| 多表分页 | 关注 COUNT SQL |
| 深分页 | 使用游标分页或限制 offset |
分页查询示例:
public PageResult<UserPageVO> pageUser(UserPageQuery query) {
Page<UserEntity> page = Page.of(query.getPageNum(), query.getPageSize());
Page<UserEntity> result = this.lambdaQuery()
.select(
UserEntity::getId,
UserEntity::getUsername,
UserEntity::getNickname,
UserEntity::getPhone,
UserEntity::getStatus,
UserEntity::getCreateTime
)
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, query.getStatus())
.orderByDesc(UserEntity::getCreateTime)
.page(page);
List<UserPageVO> records = CollUtil.isEmpty(result.getRecords())
? List.of()
: userConvert.toPageVOList(result.getRecords());
return PageResult.of(result.getCurrent(), result.getSize(), result.getTotal(), records);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
分页参数保护:
@Min(value = 1, message = "当前页不能小于1")
private Long pageNum = 1L;
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 200, message = "每页条数不能超过200")
private Long pageSize = 10L;2
3
4
5
6
分页优化建议:
| 场景 | 建议 |
|---|---|
| 后台管理列表 | 普通分页即可 |
| 日志列表 | 游标分页或时间范围 |
| 消息列表 | 游标分页 |
| 报表列表 | 自定义 COUNT 或汇总表 |
| 多表分页 | 先分页主表 ID,再查关联数据 |
| 大数据导出 | 不走普通分页接口,使用异步导出 |
深分页优化
深分页指 LIMIT offset, size 中 offset 很大,例如 LIMIT 1000000, 20。数据库需要跳过大量记录后再返回目标数据,性能会随 offset 增大而下降。
典型慢 SQL:
SELECT
id,
order_no,
pay_amount,
create_time
FROM biz_order
WHERE tenant_id = 1001
AND deleted = 0
ORDER BY create_time DESC
LIMIT 1000000, 20;2
3
4
5
6
7
8
9
10
优化方案一:游标分页。
public List<OrderPageVO> scrollOrder(LocalDateTime lastCreateTime, Long pageSize) {
long safePageSize = Math.min(ObjectUtil.defaultIfNull(pageSize, 20L), 100L);
List<OrderEntity> records = this.lambdaQuery()
.select(
OrderEntity::getId,
OrderEntity::getOrderNo,
OrderEntity::getPayAmount,
OrderEntity::getCreateTime
)
.lt(ObjectUtil.isNotNull(lastCreateTime), OrderEntity::getCreateTime, lastCreateTime)
.orderByDesc(OrderEntity::getCreateTime)
.last("LIMIT " + safePageSize)
.list();
if (CollUtil.isEmpty(records)) {
return List.of();
}
return orderConvert.toPageVOList(records);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
上面示例中 safePageSize 由后端限制后拼接。更严谨的方式是 XML 中使用参数绑定。
<select id="scrollOrder" resultType="io.github.atengk.module.order.vo.OrderPageVO">
SELECT
id,
order_no,
pay_amount,
create_time
FROM biz_order
WHERE tenant_id = #{tenantId}
AND deleted = 0
<if test="lastCreateTime != null">
AND create_time < #{lastCreateTime}
</if>
ORDER BY create_time DESC
LIMIT #{pageSize}
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
优化方案二:先查主键,再回表。
SELECT
o.id,
o.order_no,
o.pay_amount,
o.create_time
FROM biz_order o
INNER JOIN (
SELECT id
FROM biz_order
WHERE tenant_id = 1001
AND deleted = 0
ORDER BY create_time DESC
LIMIT 1000000, 20
) t ON t.id = o.id
ORDER BY o.create_time DESC;2
3
4
5
6
7
8
9
10
11
12
13
14
15
优化方案三:限制最大 offset。
private void checkPageOffset(PageQuery query) {
long maxOffset = 10000L;
long offset = (query.getPageNum() - 1) * query.getPageSize();
if (offset > maxOffset) {
throw new BusinessException("查询页数过深,请缩小筛选条件后重试");
}
}2
3
4
5
6
7
8
深分页优化建议:
| 场景 | 推荐方案 |
|---|---|
| 后台普通列表 | 限制最大 offset |
| 日志、消息 | 游标分页 |
| 时间流水 | 强制时间范围 |
| 大表导出 | 异步导出 |
| 报表查询 | 汇总表或报表库 |
| 用户体验 | 不提供跳到极深页功能 |
COUNT 查询优化
COUNT 查询在分页接口中经常被忽视。复杂 JOIN、分组、子查询、大表过滤都会导致 COUNT 慢。MyBatis-Plus 的 Page 支持 searchCount 控制是否查询总数,也支持 countId 指定 XML 自定义 COUNT 查询;分页插件的 Page 模型也提供 optimizeCountSql 和 optimizeJoinOfCountSql 等参数。(MyBatis-Plus)
关闭 COUNT:
Page<OrderEntity> page = Page.of(query.getPageNum(), query.getPageSize());
page.setSearchCount(false);2
适合场景:
| 场景 | 是否适合关闭 COUNT |
|---|---|
| 移动端加载更多 | 适合 |
| 消息列表 | 适合 |
| 日志滚动查询 | 适合 |
| 后台管理分页 | 通常不适合 |
| 报表分页 | 视需求而定 |
自定义 COUNT:
<select id="selectOrderPage" resultType="io.github.atengk.module.order.vo.OrderPageVO">
SELECT
o.id,
o.order_no,
o.pay_amount,
c.customer_name,
o.create_time
FROM biz_order o
LEFT JOIN biz_customer c ON c.id = o.customer_id AND c.deleted = 0
WHERE o.deleted = 0
<if test="query.orderNo != null and query.orderNo != ''">
AND o.order_no = #{query.orderNo}
</if>
ORDER BY o.create_time DESC
</select>
<select id="selectOrderPageCount" resultType="java.lang.Long">
SELECT
COUNT(1)
FROM biz_order o
WHERE o.deleted = 0
<if test="query.orderNo != null and query.orderNo != ''">
AND o.order_no = #{query.orderNo}
</if>
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Service 设置 countId:
Page<OrderPageVO> page = Page.of(query.getPageNum(), query.getPageSize());
page.setCountId("selectOrderPageCount");
Page<OrderPageVO> result = orderMapper.selectOrderPage(page, query);
return PageResult.of(result);2
3
4
5
COUNT 优化建议:
| 场景 | 建议 |
|---|---|
| 简单单表 | 默认 COUNT 通常可以 |
| 多表 JOIN | 自定义 COUNT |
| GROUP BY | 谨慎,COUNT 可能很慢 |
| 大表无条件 COUNT | 避免频繁调用 |
| 实时总数要求低 | 使用缓存或近似值 |
| 移动端加载更多 | 关闭 COUNT |
| 报表统计 | 使用汇总表 |
N 加一查询治理
N+1 查询和循环查询类似,但更强调 ORM 或业务组装时“查询主列表后再查询 N 次关联数据”。MyBatis-Plus 不会自动帮你消除 N+1,必须在业务代码中治理。
常见 N+1 场景:
| 主数据 | 关联数据 |
|---|---|
| 用户列表 | 角色列表 |
| 订单列表 | 订单明细 |
| 商品列表 | 分类名称 |
| 客户列表 | 跟进记录 |
| 部门列表 | 负责人用户 |
| 字典类型 | 字典项列表 |
治理方式:
| 方案 | 适用场景 |
|---|---|
| JOIN 查询 | 一对一或多对一 |
| 批量查询后 Map 组装 | 一对多或复杂回显 |
| 缓存 | 字典、部门、配置 |
| 冗余字段 | 低频变更字段,例如快照名称 |
| 专用宽表 | 报表或查询高频页面 |
| 异步预计算 | 大看板和统计页面 |
订单列表带明细数量示例:
<select id="selectOrderItemCountByOrderIds" resultType="io.github.atengk.module.order.vo.OrderItemCountVO">
SELECT
order_id,
COUNT(1) AS item_count
FROM biz_order_item
WHERE deleted = 0
AND order_id IN
<foreach collection="orderIds" item="orderId" open="(" separator="," close=")">
#{orderId}
</foreach>
GROUP BY order_id
</select>2
3
4
5
6
7
8
9
10
11
12
VO:
package io.github.atengk.module.order.vo;
import lombok.Getter;
import lombok.Setter;
/**
* 订单明细数量返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class OrderItemCountVO {
/**
* 订单 ID
*/
private Long orderId;
/**
* 明细数量
*/
private Long itemCount;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Service 组装:
public List<OrderPageVO> fillItemCount(List<OrderPageVO> orders) {
if (CollUtil.isEmpty(orders)) {
return List.of();
}
List<Long> orderIds = orders.stream()
.map(OrderPageVO::getId)
.toList();
List<OrderItemCountVO> itemCounts = orderItemMapper.selectOrderItemCountByOrderIds(orderIds);
Map<Long, Long> itemCountMap = itemCounts.stream()
.collect(Collectors.toMap(OrderItemCountVO::getOrderId, OrderItemCountVO::getItemCount, (a, b) -> a));
orders.forEach(order -> order.setItemCount(itemCountMap.getOrDefault(order.getId(), 0L)));
return orders;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
N+1 治理建议:列表页不要在循环中调用 Mapper,不要在循环中调用远程服务,不要在 MapStruct 转换方法中查询数据库。
大字段查询优化
大字段包括 TEXT、LONGTEXT、BLOB、大 JSON、富文本、附件内容、请求响应报文等。列表页查询大字段会显著增加 IO、网络传输、内存占用和序列化成本。
大字段示例:
content LONGTEXT NULL COMMENT '正文内容',
request_body LONGTEXT NULL COMMENT '请求报文',
response_body LONGTEXT NULL COMMENT '响应报文',
extra_json JSON NULL COMMENT '扩展信息'2
3
4
列表查询不要返回大字段:
<select id="selectArticlePage" resultType="io.github.atengk.module.cms.vo.ArticlePageVO">
SELECT
id,
title,
author_name,
status,
publish_time,
create_time
FROM cms_article
WHERE deleted = 0
ORDER BY create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
详情查询再返回大字段:
<select id="selectArticleDetail" resultType="io.github.atengk.module.cms.vo.ArticleDetailVO">
SELECT
id,
title,
author_name,
content,
status,
publish_time,
create_time,
update_time
FROM cms_article
WHERE id = #{id}
AND deleted = 0
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
如果大字段非常大,可以考虑拆表:
CREATE TABLE cms_article (
id BIGINT NOT NULL COMMENT '文章ID',
title VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标题',
author_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '作者',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_status_time (status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章主表';
CREATE TABLE cms_article_content (
article_id BIGINT NOT NULL COMMENT '文章ID',
content LONGTEXT NULL COMMENT '文章内容',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (article_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章内容表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
大字段优化建议:
| 场景 | 建议 |
|---|---|
| 列表页 | 不返回大字段 |
| 详情页 | 按需查询 |
| 富文本 | 可拆到内容表 |
| 附件 | 存对象存储,数据库只存元信息 |
| 日志报文 | 可压缩、截断或冷热分离 |
| JSON 扩展 | 高频查询字段不要放 JSON |
| 导出 | 避免导出大字段,或异步导出 |
数据库连接池优化
Spring Boot 在存在 HikariCP 时会优先使用 HikariCP;使用 spring-boot-starter-jdbc 或 spring-boot-starter-data-jpa 通常会自动带上 HikariCP 依赖。连接池配置由 spring.datasource.* 或 spring.datasource.hikari.* 控制。(Home)
HikariCP 配置示例:
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/mybatis_plus_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
pool-name: MyBatisPlusHikariPool
# 最大连接数,不能盲目调大
maximum-pool-size: 30
# 最小空闲连接数
minimum-idle: 5
# 获取连接超时时间,单位毫秒
connection-timeout: 30000
# 空闲连接最大存活时间,单位毫秒
idle-timeout: 600000
# 连接最大生命周期,单位毫秒
max-lifetime: 1800000
# 连接泄漏检测阈值,单位毫秒,排查问题时开启
leak-detection-threshold: 600002
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
连接池参数建议:
| 参数 | 建议 |
|---|---|
maximum-pool-size | 根据数据库承载、实例数、接口并发计算 |
minimum-idle | 不要过高,避免空闲连接过多 |
connection-timeout | 不宜过大,避免请求长时间挂起 |
idle-timeout | 保持默认或按运维要求调整 |
max-lifetime | 小于数据库连接最大存活时间 |
leak-detection-threshold | 排查连接泄漏时开启 |
| 多数据源 | 每个数据源都要控制连接池大小 |
连接池常见问题:
| 现象 | 可能原因 |
|---|---|
| 获取连接超时 | 慢 SQL、大事务、连接池太小、连接泄漏 |
| 数据库连接数爆满 | 应用实例多、每实例连接池过大 |
| 偶发连接断开 | max-lifetime 大于数据库连接超时 |
| 接口整体变慢 | 连接池排队等待 |
| CPU 不高但接口慢 | 可能在等数据库连接或锁 |
| 批量任务影响接口 | 批量任务占满连接池 |
连接池优化建议:
| 场景 | 建议 |
|---|---|
| 普通业务应用 | 不盲目加大连接池 |
| 多实例部署 | 连接池总和不能超过数据库承载 |
| 批量任务 | 使用独立线程池和限流 |
| 报表查询 | 走报表库或独立数据源 |
| 慢 SQL | 优先优化 SQL,而不是加连接 |
| 连接泄漏 | 开启泄漏检测并检查事务和流式查询 |
| 大事务 | 缩短事务时间,减少连接占用 |
数据库连接池不是越大越好。连接数过大可能让数据库线程调度、锁竞争和内存压力上升,反而降低整体吞吐。
SQL 性能优化检查清单
开发和代码评审时,建议按以下清单检查:
| 检查项 | 要求 |
|---|---|
| SQL 日志 | 开发环境能看到最终 SQL |
| 执行计划 | 慢 SQL 必须分析 EXPLAIN |
| 查询字段 | 禁止无脑 SELECT * |
| 索引 | 高频查询条件和排序字段有合适索引 |
| 模糊查询 | 避免大表左模糊和全模糊 |
| 分页 | 限制 pageSize,处理深分页 |
| COUNT | 复杂分页考虑自定义 COUNT 或关闭 COUNT |
| JOIN | 关联字段有索引,一对多分页谨慎 |
| 循环查询 | 禁止循环中查询数据库 |
| N+1 | 批量查询后 Map 组装 |
| 大字段 | 列表页不查询大字段 |
| 批量写入 | 使用批量 API,控制批次大小 |
| 事务 | 避免大事务和事务中外部调用 |
| 连接池 | 参数与数据库承载匹配 |
| 缓存 | 字典、部门、配置类数据可缓存 |
| 生产日志 | 不长期打印完整 SQL 和敏感参数 |
SQL 性能优化的优先级通常是:先减少无效查询和 N+1,再优化索引和 SQL,再处理分页和 COUNT,最后才考虑连接池、缓存、分库分表等架构层面调整。
安全控制
安全控制用于降低 SQL 注入、越权访问、数据权限绕过、敏感字段泄露和危险 SQL 拼接带来的风险。MyBatis-Plus 简化了 CRUD,但并不意味着所有 Wrapper 写法天然安全。只要存在前端字段透传、SQL 片段拼接、动态排序、动态查询字段、apply、last、inSql、自定义 XML SQL,就必须做白名单、参数绑定和权限校验。
SQL 注入风险
SQL 注入的本质是把不可信输入当作 SQL 结构的一部分执行。普通值参数使用 #{}、Wrapper 的 eq、like、in 等参数化方法通常较安全;但表名、字段名、排序字段、SQL 片段、last 片段、apply 片段、${} 拼接都属于高风险区域。
常见风险写法如下:
wrapper.orderByDesc(query.getOrderBy());
wrapper.last("LIMIT " + query.getLimit());
wrapper.apply("DATE(create_time) = '" + query.getDate() + "'");
wrapper.inSql(UserEntity::getId, query.getSubSql());2
3
4
5
6
7
XML 中的高风险写法:
<select id="selectListByColumn" resultType="io.github.atengk.module.system.user.entity.UserEntity">
SELECT *
FROM sys_user
WHERE ${column} = #{value}
ORDER BY ${orderBy}
</select>2
3
4
5
6
SQL 注入防护原则:
| 风险点 | 防护方式 |
|---|---|
| 普通字段值 | 使用 #{} 或 Wrapper 参数方法 |
| 排序字段 | 使用后端白名单映射 |
| 查询字段 | 使用后端白名单映射 |
| 表名 | 禁止前端传入完整表名 |
| SQL 片段 | 禁止前端传入 |
last | 只允许后端固定常量 |
apply | 使用占位符,不拼接原始值 |
${} | 原则上禁用,特殊场景必须白名单 |
| 动态导出字段 | 后端枚举控制 |
安全写法示例:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.eq(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.like(StrUtil.isNotBlank(query.getNickname()), UserEntity::getNickname, query.getNickname())
.eq(ObjectUtil.isNotNull(query.getStatus()), UserEntity::getStatus, query.getStatus())
.orderByDesc(UserEntity::getCreateTime);2
3
4
5
普通查询条件不要拼接 SQL 字符串。能用 Lambda Wrapper 表达的条件,优先用 Lambda Wrapper。
Wrapper 拼接安全
Wrapper 本身不是风险源,风险来自错误使用方式。推荐优先使用 LambdaQueryWrapper 和 LambdaUpdateWrapper,通过实体 Getter 引用字段,减少硬编码字段名和前端字段透传风险。
推荐写法:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getStatus, 1)
.like(StrUtil.isNotBlank(keyword), UserEntity::getUsername, keyword)
.orderByDesc(UserEntity::getCreateTime);2
3
4
谨慎写法:
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.eq("status", 1)
.like(StrUtil.isNotBlank(keyword), "username", keyword)
.orderByDesc("create_time");2
3
4
禁止写法:
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.eq(query.getColumn(), query.getValue());
wrapper.orderByDesc(query.getOrderBy());2
3
Wrapper 安全规范:
| 写法 | 风险 | 建议 |
|---|---|---|
lambdaQuery().eq(UserEntity::getUsername, value) | 低 | 推荐 |
queryWrapper.eq("username", value) | 中 | 字段固定时可用 |
queryWrapper.eq(frontColumn, value) | 高 | 禁止 |
orderByDesc(frontField) | 高 | 必须白名单 |
select(frontFields) | 高 | 必须白名单 |
last(frontSql) | 极高 | 禁止 |
apply(frontSql) | 极高 | 禁止 |
inSql(frontSql) | 极高 | 禁止 |
Wrapper 中的字段名、排序字段、SQL 片段、表名都不能直接来自前端。
apply 使用风险
apply 用于拼接自定义 SQL 片段,适合数据库函数、特殊条件等场景。它的风险在于开发者容易直接拼接字符串,导致 SQL 注入。
危险写法:
wrapper.apply("DATE(create_time) = '" + query.getDate() + "'");推荐写法:使用占位符。
wrapper.apply(ObjectUtil.isNotNull(query.getDate()), "DATE(create_time) = {0}", query.getDate());更推荐写法:改成范围查询,避免对索引字段使用函数。
LocalDate date = query.getDate();
LocalDateTime startTime = date.atStartOfDay();
LocalDateTime endTime = date.plusDays(1).atStartOfDay();
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.ge(UserEntity::getCreateTime, startTime)
.lt(UserEntity::getCreateTime, endTime);2
3
4
5
6
7
apply 使用建议:
| 场景 | 建议 |
|---|---|
| 日期函数 | 优先改成范围查询 |
| 数据库函数 | 可使用占位符 |
| 前端 SQL 片段 | 禁止 |
| 拼接字符串 | 禁止 |
| 复杂条件 | 优先 XML |
| 可索引字段 | 避免函数包裹字段 |
如果 apply 里需要写复杂 SQL,优先考虑 XML。XML 的可读性、参数绑定和审查成本通常更好。
last 使用风险
last 会直接把 SQL 片段追加到最终 SQL 末尾。它不会进行参数化处理,风险很高。典型用途是追加固定的 LIMIT 1,但不能接收前端传入内容。
允许的固定写法:
UserEntity user = this.lambdaQuery()
.eq(UserEntity::getUsername, username)
.last("LIMIT 1")
.one();2
3
4
禁止写法:
wrapper.last(query.getSqlTail());
wrapper.last("LIMIT " + query.getPageSize());
wrapper.last("ORDER BY " + query.getOrderBy());2
3
4
5
如果确实需要限制条数,应先在后端做数值校验:
long safeLimit = Math.min(ObjectUtil.defaultIfNull(query.getLimit(), 10L), 100L);
List<UserEntity> users = this.lambdaQuery()
.eq(UserEntity::getStatus, 1)
.orderByDesc(UserEntity::getCreateTime)
.last("LIMIT " + safeLimit)
.list();2
3
4
5
6
7
更推荐使用 MyBatis-Plus 分页:
Page<UserEntity> page = Page.of(1, 100);
Page<UserEntity> result = this.lambdaQuery()
.eq(UserEntity::getStatus, 1)
.orderByDesc(UserEntity::getCreateTime)
.page(page);2
3
4
5
6
last 使用规范:
| 场景 | 建议 |
|---|---|
固定 LIMIT 1 | 可以 |
| 动态 limit | 后端校验后谨慎使用 |
| 动态排序 | 禁止,改用白名单 |
| 前端 SQL 尾巴 | 禁止 |
| 复杂分页 | 使用分页插件 |
| 数据库 Hint | 谨慎,仅后端固定内容 |
inSql 使用风险
inSql 会把子查询 SQL 直接拼入 IN (...),适合后端固定子查询,不适合接收前端 SQL。前端传入子查询片段属于严重 SQL 注入风险。
危险写法:
wrapper.inSql(UserEntity::getId, query.getSubSql());安全写法:普通集合使用 in。
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.in(CollUtil.isNotEmpty(userIds), UserEntity::getId, userIds);2
后端固定子查询可以使用:
LambdaQueryWrapper<UserEntity> wrapper = new LambdaQueryWrapper<UserEntity>()
.inSql(UserEntity::getId, """
SELECT user_id
FROM sys_user_role
WHERE role_id = 1
AND deleted = 0
""");2
3
4
5
6
7
更推荐 XML 写法,参数绑定更清晰:
<select id="selectUsersByRoleId" resultType="io.github.atengk.module.system.user.entity.UserEntity">
SELECT
u.id,
u.username,
u.nickname,
u.status,
u.create_time
FROM sys_user u
WHERE u.deleted = 0
AND u.id IN (
SELECT ur.user_id
FROM sys_user_role ur
WHERE ur.role_id = #{roleId}
AND ur.deleted = 0
)
ORDER BY u.create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
inSql 使用建议:
| 场景 | 建议 |
|---|---|
| 普通 ID 列表 | 使用 in |
| 前端传 SQL | 禁止 |
| 后端固定子查询 | 可用 |
| 需要传参子查询 | 推荐 XML |
| 复杂子查询 | 推荐 XML |
| 大量 IN 参数 | 分批查询 |
前端排序字段白名单
前端排序字段不能直接作为数据库字段使用。推荐前端传业务字段名,例如 createTime、username、sortOrder,后端通过白名单映射到实体字段或数据库字段。
排序查询对象示例:
package io.github.atengk.common.query;
import cn.hutool.core.util.StrUtil;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.Setter;
/**
* 通用分页查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class PageQuery {
/**
* 当前页
*/
@Min(value = 1, message = "当前页不能小于1")
private Long pageNum = 1L;
/**
* 每页条数
*/
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 200, message = "每页条数不能超过200")
private Long pageSize = 10L;
/**
* 排序字段
*/
private String orderBy;
/**
* 排序方向:asc 或 desc
*/
private String orderDirection = "desc";
/**
* 获取安全排序方向
*
* @return 排序方向
*/
public String getSafeOrderDirection() {
return StrUtil.equalsIgnoreCase(orderDirection, "asc") ? "asc" : "desc";
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
排序白名单处理类如下。
package io.github.atengk.module.system.user.support;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import io.github.atengk.module.system.user.entity.UserEntity;
import java.util.Map;
/**
* 用户排序字段白名单
*
* @author Ateng
* @since 2026-05-05
*/
public final class UserSortFieldWhitelist {
private static final Map<String, SFunction<UserEntity, ?>> SORT_FIELD_MAP = Map.of(
"createTime", UserEntity::getCreateTime,
"updateTime", UserEntity::getUpdateTime,
"sortOrder", UserEntity::getSortOrder,
"username", UserEntity::getUsername
);
private UserSortFieldWhitelist() {
}
/**
* 根据前端排序字段获取实体排序字段
*
* @param orderBy 前端排序字段
* @return 实体排序字段
*/
public static SFunction<UserEntity, ?> getSortField(String orderBy) {
return SORT_FIELD_MAP.get(orderBy);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Service 中应用排序白名单。
private void applyOrder(UserPageQuery query, LambdaQueryWrapper<UserEntity> wrapper) {
SFunction<UserEntity, ?> sortField = UserSortFieldWhitelist.getSortField(query.getOrderBy());
if (sortField == null) {
wrapper.orderByDesc(UserEntity::getCreateTime);
return;
}
boolean asc = StrUtil.equalsIgnoreCase(query.getSafeOrderDirection(), "asc");
wrapper.orderBy(true, asc, sortField);
}2
3
4
5
6
7
8
9
10
排序字段安全规范:
| 规范项 | 建议 |
|---|---|
| 前端字段 | 使用业务字段名 |
| 后端映射 | 使用白名单 |
| 默认排序 | 必须设置 |
| 排序方向 | 只允许 asc、desc |
| 数据库字段名 | 不直接暴露给前端 |
| 非法字段 | 使用默认排序或返回参数错误 |
| 多字段排序 | 后端控制允许范围 |
不要让前端传 create_time desc、id desc limit 1 这类内容。
查询字段白名单
查询字段白名单用于控制接口允许返回哪些字段,尤其是导出接口、自定义列表字段、低代码查询接口等场景。查询字段不能直接使用前端传入字段名,否则可能泄露敏感字段或造成 SQL 注入。
用户查询字段枚举如下。
package io.github.atengk.module.system.user.enums;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 用户查询字段枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum UserQueryFieldEnum {
/**
* 用户 ID
*/
ID("id", "id", "用户ID"),
/**
* 用户名
*/
USERNAME("username", "username", "用户名"),
/**
* 昵称
*/
NICKNAME("nickname", "nickname", "昵称"),
/**
* 手机号
*/
PHONE("phone", "phone", "手机号"),
/**
* 状态
*/
STATUS("status", "status", "状态"),
/**
* 创建时间
*/
CREATE_TIME("createTime", "create_time", "创建时间");
/**
* 前端字段名
*/
private final String fieldName;
/**
* 数据库字段名
*/
private final String columnName;
/**
* 字段说明
*/
private final String description;
/**
* 根据前端字段名获取枚举
*
* @param fieldName 前端字段名
* @return 查询字段枚举
*/
public static UserQueryFieldEnum ofFieldName(String fieldName) {
return Arrays.stream(values())
.filter(item -> ObjectUtil.equals(item.getFieldName(), fieldName))
.findFirst()
.orElse(null);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
字段白名单转换工具如下。
package io.github.atengk.module.system.user.support;
import cn.hutool.core.collection.CollUtil;
import io.github.atengk.module.system.user.enums.UserQueryFieldEnum;
import java.util.List;
import java.util.Objects;
/**
* 用户查询字段白名单
*
* @author Ateng
* @since 2026-05-05
*/
public final class UserQueryFieldWhitelist {
private static final List<String> DEFAULT_COLUMNS = List.of(
"id",
"username",
"nickname",
"phone",
"status",
"create_time"
);
private UserQueryFieldWhitelist() {
}
/**
* 获取安全查询字段列表
*
* @param fieldNames 前端字段名列表
* @return 数据库字段名列表
*/
public static List<String> getSafeColumns(List<String> fieldNames) {
if (CollUtil.isEmpty(fieldNames)) {
return DEFAULT_COLUMNS;
}
List<String> columns = fieldNames.stream()
.map(UserQueryFieldEnum::ofFieldName)
.filter(Objects::nonNull)
.map(UserQueryFieldEnum::getColumnName)
.toList();
if (CollUtil.isEmpty(columns)) {
return DEFAULT_COLUMNS;
}
return columns;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
使用示例:
public List<UserEntity> listUserBySelectedFields(List<String> fieldNames) {
List<String> columns = UserQueryFieldWhitelist.getSafeColumns(fieldNames);
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.select(columns)
.eq("deleted", 0)
.orderByDesc("create_time");
return this.list(wrapper);
}2
3
4
5
6
7
8
9
10
查询字段白名单建议:
| 场景 | 建议 |
|---|---|
| 普通列表接口 | 固定 VO 字段 |
| 动态导出 | 字段枚举白名单 |
| 低代码查询 | 字段、排序、条件都要白名单 |
| 敏感字段 | 默认不可选 |
| 大字段 | 默认不可选 |
| 数据库字段名 | 不直接暴露给前端 |
| 非法字段 | 忽略或返回参数错误 |
不建议开放任意字段查询接口。尤其是 password、salt、token、secret_key、id_card、bank_card、access_key 等字段必须禁止返回。
数据权限绕过防护
数据权限绕过常发生在自定义 SQL、@InterceptorIgnore、管理员接口、导出接口、详情接口、批量操作接口和跨租户查询接口中。不能只保护分页查询,详情、修改、删除、导出同样需要数据权限。
常见绕过方式:
| 场景 | 风险 |
|---|---|
| 详情接口只按 ID 查 | 用户可能访问无权限数据详情 |
| 修改接口只按 ID 改 | 用户可能修改他人数据 |
| 删除接口只按 ID 删 | 用户可能删除他人数据 |
| 导出接口忽略权限 | 用户可能导出全量数据 |
| 自定义 XML 未加权限 | 数据权限插件未覆盖或被忽略 |
使用 @InterceptorIgnore | 绕过租户或数据权限 |
| 管理员接口过宽 | 普通管理员看到平台全量数据 |
| 批量接口 | 混入无权限 ID |
详情查询建议带权限条件,或在查询后做权限校验。
public UserDetailVO getUserDetail(Long id) {
UserEntity entity = this.lambdaQuery()
.eq(UserEntity::getId, id)
.eq(UserEntity::getCreateBy, currentUserContext.getLoginUserOrDefault().getUserId())
.one();
if (entity == null) {
throw new BusinessException(ResultCode.DATA_NOT_FOUND, "用户不存在或无权限访问");
}
return userConvert.toDetailVO(entity);
}2
3
4
5
6
7
8
9
10
11
12
批量删除前校验权限:
@Transactional(rollbackFor = Exception.class)
public void batchDeleteUser(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
return;
}
Long currentUserId = currentUserContext.getLoginUserOrDefault().getUserId();
long allowedCount = this.lambdaQuery()
.in(UserEntity::getId, ids)
.eq(UserEntity::getCreateBy, currentUserId)
.count();
if (allowedCount != ids.size()) {
throw new BusinessException("存在无权限操作的数据");
}
this.removeBatchByIds(ids, 1000);
log.info("批量删除用户成功,数量:{}", ids.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
限制 @InterceptorIgnore 使用:
@InterceptorIgnore(tenantLine = "true")
public List<TenantEntity> selectAllTenant() {
// 仅平台管理员可调用
}2
3
4
数据权限绕过防护建议:
| 规范项 | 建议 |
|---|---|
| 详情接口 | 必须校验数据权限 |
| 修改接口 | 更新条件中带权限条件或先校验权限 |
| 删除接口 | 删除前校验权限 |
| 批量接口 | 校验所有 ID 都有权限 |
| 导出接口 | 默认受数据权限控制 |
| 忽略插件 | 必须代码评审 |
| 超级管理员 | 仅平台级账号拥有 |
| 错误提示 | 可返回“数据不存在或无权限访问” |
不要只依赖前端隐藏按钮。后端必须对每个敏感操作做权限校验。
敏感字段脱敏
敏感字段包括手机号、邮箱、身份证号、银行卡号、地址、密码、Token、密钥、AccessKey、SecretKey 等。接口返回时应根据业务场景做脱敏,不应直接返回完整敏感信息。
常见脱敏字段:
| 字段 | 示例 |
|---|---|
| 手机号 | 138****8000 |
| 邮箱 | a***@example.com |
| 身份证号 | 110***********1234 |
| 银行卡号 | 6222 **** **** 1234 |
| 地址 | 按业务规则部分隐藏 |
| 密码 | 永不返回 |
| Token | 永不返回 |
| 密钥 | 永不返回 |
| API Secret | 永不返回 |
简单脱敏可以直接使用 Hutool DesensitizedUtil。
public UserDetailVO getUserDetail(Long id) {
UserEntity entity = this.getRequiredById(id);
UserDetailVO detailVO = userConvert.toDetailVO(entity);
detailVO.setPhone(DesensitizedUtil.mobilePhone(detailVO.getPhone()));
detailVO.setEmail(DesensitizedUtil.email(detailVO.getEmail()));
return detailVO;
}2
3
4
5
6
7
8
9
如果项目希望统一处理 VO 字段脱敏,可以使用注解 + Jackson 序列化器。
脱敏类型枚举如下。
package io.github.atengk.framework.desensitize;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 敏感字段脱敏类型枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum SensitiveTypeEnum {
/**
* 手机号
*/
MOBILE_PHONE,
/**
* 邮箱
*/
EMAIL,
/**
* 身份证号
*/
ID_CARD,
/**
* 银行卡号
*/
BANK_CARD,
/**
* 中文姓名
*/
CHINESE_NAME,
/**
* 地址
*/
ADDRESS
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
脱敏注解如下。
package io.github.atengk.framework.desensitize;
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* 敏感字段脱敏注解
*
* @author Ateng
* @since 2026-05-05
*/
@Documented
@Target(FIELD)
@Retention(RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = SensitiveJsonSerializer.class)
public @interface Sensitive {
/**
* 脱敏类型
*
* @return 脱敏类型
*/
SensitiveTypeEnum type();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Jackson 脱敏序列化器如下。
package io.github.atengk.framework.desensitize;
import cn.hutool.core.util.DesensitizedUtil;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import java.io.IOException;
/**
* 敏感字段 JSON 序列化器
*
* @author Ateng
* @since 2026-05-05
*/
public class SensitiveJsonSerializer extends JsonSerializer<String> implements ContextualSerializer {
private SensitiveTypeEnum sensitiveType;
public SensitiveJsonSerializer() {
}
public SensitiveJsonSerializer(SensitiveTypeEnum sensitiveType) {
this.sensitiveType = sensitiveType;
}
/**
* 序列化敏感字段
*
* @param value 字段值
* @param gen JSON 生成器
* @param serializers 序列化提供者
* @throws IOException IO 异常
*/
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (StrUtil.isBlank(value)) {
gen.writeString(value);
return;
}
gen.writeString(desensitize(value));
}
/**
* 根据字段注解创建上下文序列化器
*
* @param prov 序列化提供者
* @param property Bean 属性
* @return JSON 序列化器
* @throws JsonMappingException JSON 映射异常
*/
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
if (property == null) {
return prov.findNullValueSerializer(null);
}
Sensitive sensitive = property.getAnnotation(Sensitive.class);
if (sensitive == null) {
sensitive = property.getContextAnnotation(Sensitive.class);
}
if (sensitive == null) {
return prov.findValueSerializer(property.getType(), property);
}
return new SensitiveJsonSerializer(sensitive.type());
}
/**
* 执行脱敏
*
* @param value 原始值
* @return 脱敏后的值
*/
private String desensitize(String value) {
if (SensitiveTypeEnum.MOBILE_PHONE.equals(sensitiveType)) {
return DesensitizedUtil.mobilePhone(value);
}
if (SensitiveTypeEnum.EMAIL.equals(sensitiveType)) {
return DesensitizedUtil.email(value);
}
if (SensitiveTypeEnum.ID_CARD.equals(sensitiveType)) {
return DesensitizedUtil.idCardNum(value, 4, 4);
}
if (SensitiveTypeEnum.BANK_CARD.equals(sensitiveType)) {
return DesensitizedUtil.bankCard(value);
}
if (SensitiveTypeEnum.CHINESE_NAME.equals(sensitiveType)) {
return DesensitizedUtil.chineseName(value);
}
if (SensitiveTypeEnum.ADDRESS.equals(sensitiveType)) {
return DesensitizedUtil.address(value, 8);
}
return value;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
VO 中使用:
package io.github.atengk.module.system.user.vo;
import io.github.atengk.framework.desensitize.Sensitive;
import io.github.atengk.framework.desensitize.SensitiveTypeEnum;
import lombok.Getter;
import lombok.Setter;
/**
* 用户详情返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserDetailVO {
/**
* 用户 ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 手机号
*/
@Sensitive(type = SensitiveTypeEnum.MOBILE_PHONE)
private String phone;
/**
* 邮箱
*/
@Sensitive(type = SensitiveTypeEnum.EMAIL)
private String email;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
脱敏建议:
| 场景 | 建议 |
|---|---|
| 列表接口 | 默认脱敏 |
| 详情接口 | 根据权限决定是否脱敏 |
| 导出接口 | 默认脱敏,特殊权限才允许完整导出 |
| 日志 | 不记录完整敏感字段 |
| 密码字段 | 不返回,不脱敏返回 |
| Token | 不返回,不写日志 |
| SecretKey | 只在创建时展示一次,之后不展示 |
脱敏不是加密。脱敏只用于展示保护,不能替代数据库加密、传输加密和权限控制。
接口越权防护
接口越权包括水平越权和垂直越权。水平越权是用户访问或操作同级其他用户的数据;垂直越权是普通用户访问管理员接口或执行管理员操作。后端必须同时做接口权限和数据权限校验。
常见越权场景:
| 类型 | 示例 |
|---|---|
| 水平越权 | 用户 A 访问用户 B 的订单详情 |
| 水平越权 | 销售 A 修改销售 B 的客户 |
| 垂直越权 | 普通用户调用管理员删除接口 |
| 垂直越权 | 普通管理员调用平台超管接口 |
| 参数越权 | 前端传 tenantId 查询其他租户数据 |
| 批量越权 | 批量删除时混入无权限 ID |
接口越权防护建议:
| 防护点 | 建议 |
|---|---|
| 登录认证 | 所有业务接口必须认证 |
| 接口权限 | 使用注解、网关或安全框架控制 |
| 数据权限 | 查询、详情、修改、删除都要校验 |
| 租户 ID | 从登录上下文获取,不信任前端 |
| 用户 ID | 用户只能操作自己的数据,除非有授权 |
| 批量操作 | 校验所有数据都有权限 |
| 管理接口 | 区分租户管理员和平台管理员 |
| 审计日志 | 记录敏感操作 |
用户只能修改自己资料的示例:
@Transactional(rollbackFor = Exception.class)
public void updateMyProfile(UserProfileUpdateDTO dto) {
Long currentUserId = currentUserContext.getLoginUserOrDefault().getUserId();
UserEntity entity = this.getById(currentUserId);
if (entity == null) {
throw new BusinessException("当前用户不存在");
}
entity.setNickname(dto.getNickname());
entity.setEmail(dto.getEmail());
boolean updated = this.updateById(entity);
if (!updated) {
throw new BusinessException("修改个人资料失败");
}
log.info("用户修改个人资料成功,用户ID:{}", currentUserId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
管理员修改用户资料的示例:需要先校验接口权限,再校验数据范围。
@Transactional(rollbackFor = Exception.class)
public void adminUpdateUser(Long userId, UserUpdateDTO dto) {
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
if (!permissionService.hasPermission(loginUser.getUserId(), "system:user:update")) {
throw new PermissionDeniedException("无用户修改权限");
}
boolean hasDataPermission = dataPermissionService.hasUserDataPermission(loginUser, userId);
if (!hasDataPermission) {
throw new PermissionDeniedException("无权限修改当前用户");
}
UserEntity entity = this.getById(userId);
if (entity == null) {
throw new BusinessException(ResultCode.DATA_NOT_FOUND, "用户不存在");
}
userConvert.updateEntity(dto, entity);
this.updateById(entity);
log.info("管理员修改用户成功,操作人ID:{},目标用户ID:{}", loginUser.getUserId(), userId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
权限服务接口示例:
package io.github.atengk.framework.security;
/**
* 权限校验服务
*
* @author Ateng
* @since 2026-05-05
*/
public interface PermissionService {
/**
* 判断用户是否拥有指定权限
*
* @param userId 用户 ID
* @param permission 权限标识
* @return 是否拥有权限
*/
boolean hasPermission(Long userId, String permission);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
数据权限服务接口示例:
package io.github.atengk.framework.security;
/**
* 数据权限校验服务
*
* @author Ateng
* @since 2026-05-05
*/
public interface DataPermissionService {
/**
* 判断是否拥有目标用户数据权限
*
* @param loginUser 当前登录用户
* @param targetUserId 目标用户 ID
* @return 是否拥有权限
*/
boolean hasUserDataPermission(LoginUser loginUser, Long targetUserId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
接口越权防护的核心原则是:前端隐藏按钮只负责用户体验,后端权限校验才负责安全。所有涉及数据读取、修改、删除、导出、审批、授权、租户切换、管理员操作的接口,都必须在后端完成权限校验。
参数校验
参数校验用于在请求进入业务逻辑前拦截无效数据,避免脏数据进入 Service、Mapper 和数据库。Spring Boot 3 项目使用 Jakarta Validation,即 jakarta.validation.* 包。Spring MVC 对 @RequestBody、@ModelAttribute、@RequestPart 等参数支持 @Valid / @Validated 校验;当方法参数上直接使用 @NotBlank、@Min 等约束时,会触发方法级校验,通常需要在类上添加 @Validated。(Spring Enterprise 文档)
Spring Validation 配置
Spring Boot 3 项目需要引入 spring-boot-starter-validation。该依赖会引入 Hibernate Validator 等 Bean Validation 实现。只要 Bean Validation 实现存在于 classpath,Spring Boot 会自动启用方法校验能力;目标类需要添加 @Validated 才能触发方法参数级约束校验。(Home)
文件位置:pom.xml
<!-- Spring Validation 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>2
3
4
5
Controller 层推荐写法:
@Slf4j
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
@PostMapping
public ApiResult<Long> addUser(@RequestBody @Valid UserAddDTO dto) {
Long userId = userService.addUser(dto);
return ApiResult.success(userId);
}
@GetMapping("/{id}")
public ApiResult<UserDetailVO> getUserDetail(
@PathVariable @NotNull(message = "用户ID不能为空") Long id) {
UserDetailVO detailVO = userService.getUserDetail(id);
return ApiResult.success(detailVO);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
常用校验注解如下:
| 注解 | 说明 |
|---|---|
@NotNull | 不能为 null |
@NotBlank | 字符串不能为 null、空串、空白字符 |
@NotEmpty | 集合、数组、字符串不能为空 |
@Size | 限制字符串、集合长度 |
@Min | 最小值 |
@Max | 最大值 |
@Pattern | 正则校验 |
@Email | 邮箱格式 |
@Positive | 必须为正数 |
@DecimalMin | 小数最小值 |
@DecimalMax | 小数最大值 |
@Valid | 触发嵌套对象校验 |
@Validated | 支持分组校验和方法级校验 |
参数校验建议:
| 位置 | 建议 |
|---|---|
| Controller 入参 | 使用 @Valid 或 @Validated |
| DTO 字段 | 使用 Jakarta Validation 注解 |
| 路径参数 | Controller 类添加 @Validated |
| 业务规则 | 放 Service 层,不只依赖注解 |
| 错误处理 | 交给全局异常处理器 |
| 自定义规则 | 使用自定义注解或 Service 校验 |
新增参数校验
新增参数校验用于创建数据时限制必填字段、字段长度、格式、数值范围等。新增 DTO 不应包含主键、逻辑删除、创建人、更新时间、租户 ID 等内部字段。
文件位置:src/main/java/io/github/atengk/module/system/user/dto/UserAddDTO.java
package io.github.atengk.module.system.user.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 用户新增参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserAddDTO {
/**
* 用户名
*/
@NotBlank(message = "用户名不能为空")
@Size(max = 64, message = "用户名长度不能超过64个字符")
@Pattern(regexp = "^[a-zA-Z0-9_]{4,64}$", message = "用户名只能包含字母、数字、下划线,长度为4到64个字符")
private String username;
/**
* 昵称
*/
@NotBlank(message = "昵称不能为空")
@Size(max = 64, message = "昵称长度不能超过64个字符")
private String nickname;
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
/**
* 邮箱
*/
@Email(message = "邮箱格式不正确")
@Size(max = 128, message = "邮箱长度不能超过128个字符")
private String email;
/**
* 账户余额
*/
@NotNull(message = "账户余额不能为空")
@DecimalMin(value = "0.00", message = "账户余额不能小于0")
private BigDecimal balanceAmount;
/**
* 状态:0禁用,1启用
*/
@NotNull(message = "用户状态不能为空")
private Integer status;
/**
* 备注
*/
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
新增校验注意事项:
| 字段 | 建议 |
|---|---|
username | 必填,限制格式和长度 |
nickname | 必填,限制长度 |
phone | 必填,校验手机号格式 |
email | 非必填时只校验格式 |
balanceAmount | 使用 BigDecimal,校验最小值 |
status | 必填,但是否合法需要 Service 转枚举校验 |
remark | 限制最大长度 |
唯一性校验不能只靠 DTO 注解完成,例如用户名是否重复、手机号是否已存在,应放在 Service 层,并由数据库唯一索引兜底。
修改参数校验
修改参数校验与新增不同。修改时通常必须携带 id,并且并发敏感场景建议携带 version。部分字段可以为空,表示“不修改”;如果需要清空字段,应使用专门接口或明确的 PATCH 语义。
文件位置:src/main/java/io/github/atengk/module/system/user/dto/UserUpdateDTO.java
package io.github.atengk.module.system.user.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 用户修改参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserUpdateDTO {
/**
* 用户 ID
*/
@NotNull(message = "用户ID不能为空")
private Long id;
/**
* 昵称
*/
@Size(max = 64, message = "昵称长度不能超过64个字符")
private String nickname;
/**
* 手机号
*/
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
/**
* 邮箱
*/
@Email(message = "邮箱格式不正确")
@Size(max = 128, message = "邮箱长度不能超过128个字符")
private String email;
/**
* 账户余额
*/
@DecimalMin(value = "0.00", message = "账户余额不能小于0")
private BigDecimal balanceAmount;
/**
* 状态:0禁用,1启用
*/
private Integer status;
/**
* 乐观锁版本号
*/
@NotNull(message = "版本号不能为空")
private Integer version;
/**
* 备注
*/
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
Controller 中路径 ID 覆盖请求体 ID:
@PutMapping("/{id}")
public ApiResult<Void> updateUser(
@PathVariable @NotNull(message = "用户ID不能为空") Long id,
@RequestBody @Valid UserUpdateDTO dto) {
dto.setId(id);
userService.updateUser(dto);
return ApiResult.success();
}2
3
4
5
6
7
8
修改校验建议:
| 检查项 | 建议 |
|---|---|
| 主键 ID | 必填,以路径 ID 为准 |
| 乐观锁版本 | 并发敏感表必填 |
| 空字段 | 默认表示不修改 |
| 清空字段 | 使用专门接口 |
| 状态字段 | Service 层校验状态是否合法 |
| 唯一字段 | Service 层校验并排除当前 ID |
| 数据权限 | Service 层校验目标数据是否可操作 |
分页参数校验
分页参数必须限制页码、页大小和排序字段。不能让前端传入过大的 pageSize,也不能让前端直接传数据库排序字段。
文件位置:src/main/java/io/github/atengk/common/query/PageQuery.java
package io.github.atengk.common.query;
import cn.hutool.core.util.StrUtil;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.Setter;
/**
* 通用分页查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class PageQuery {
/**
* 当前页
*/
@Min(value = 1, message = "当前页不能小于1")
private Long pageNum = 1L;
/**
* 每页条数
*/
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 200, message = "每页条数不能超过200")
private Long pageSize = 10L;
/**
* 排序字段
*/
private String orderBy;
/**
* 排序方向:asc 或 desc
*/
private String orderDirection = "desc";
/**
* 获取安全排序方向
*
* @return 排序方向
*/
public String getSafeOrderDirection() {
return StrUtil.equalsIgnoreCase(orderDirection, "asc") ? "asc" : "desc";
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
业务分页参数:
文件位置:src/main/java/io/github/atengk/module/system/user/query/UserPageQuery.java
package io.github.atengk.module.system.user.query;
import io.github.atengk.common.query.PageQuery;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户分页查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserPageQuery extends PageQuery {
/**
* 用户名
*/
@Size(max = 64, message = "用户名长度不能超过64个字符")
private String username;
/**
* 手机号
*/
@Size(max = 20, message = "手机号长度不能超过20个字符")
private String phone;
/**
* 状态
*/
private Integer status;
/**
* 创建开始时间
*/
private LocalDateTime startTime;
/**
* 创建结束时间
*/
private LocalDateTime endTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Controller 示例:
@GetMapping("/page")
public ApiResult<PageResult<UserPageVO>> pageUser(@Valid UserPageQuery query) {
PageResult<UserPageVO> pageResult = userService.pageUser(query);
return ApiResult.success(pageResult);
}2
3
4
5
分页校验建议:
| 参数 | 建议 |
|---|---|
pageNum | 最小为 1 |
pageSize | 最小为 1,最大建议 100 或 200 |
orderBy | 后端白名单映射 |
orderDirection | 只允许 asc 或 desc |
| 时间范围 | Service 层校验开始时间不能大于结束时间 |
| 深分页 | Service 层限制最大 offset |
批量参数校验
批量参数校验用于批量删除、批量修改状态、批量导入、批量绑定关系等场景。重点是限制集合不能为空、单次操作数量不能过大、集合元素不能为空、是否重复、是否越权。
批量 ID 请求对象:
文件位置:src/main/java/io/github/atengk/common/dto/BatchIdDTO.java
package io.github.atengk.common.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 批量 ID 参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class BatchIdDTO {
/**
* ID 列表
*/
@NotEmpty(message = "ID列表不能为空")
@Size(max = 1000, message = "单次最多操作1000条数据")
private List<@NotNull(message = "ID不能为空") Long> ids;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
批量删除接口:
@DeleteMapping("/batch")
public ApiResult<Void> batchDeleteUser(@RequestBody @Valid BatchIdDTO dto) {
userService.batchDeleteUser(dto.getIds());
return ApiResult.success();
}2
3
4
5
Service 层继续做去重和权限校验:
@Transactional(rollbackFor = Exception.class)
public void batchDeleteUser(List<Long> ids) {
List<Long> distinctIds = ids.stream()
.distinct()
.toList();
if (CollUtil.isEmpty(distinctIds)) {
throw new BusinessException("ID列表不能为空");
}
if (distinctIds.size() != ids.size()) {
log.warn("批量删除用户参数存在重复ID,原数量:{},去重后数量:{}", ids.size(), distinctIds.size());
}
this.checkUserDeletePermission(distinctIds);
this.removeBatchByIds(distinctIds, 1000);
log.info("批量删除用户成功,数量:{}", distinctIds.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
批量参数校验建议:
| 检查项 | 建议 |
|---|---|
| 集合为空 | 使用 @NotEmpty |
| 单次数量 | 使用 @Size(max = ...) |
| 元素为空 | 使用 List<@NotNull Long> |
| 重复 ID | Service 层去重或提示 |
| 数据存在性 | Service 层校验 |
| 数据权限 | Service 层逐批校验 |
| 操作范围 | 批量删除、批量修改必须限制最大数量 |
分组校验
分组校验用于同一个 DTO 在新增和修改场景下使用不同校验规则。例如新增不需要 ID,修改必须有 ID;新增密码必填,修改密码可以不传。
定义校验分组:
文件位置:src/main/java/io/github/atengk/common/validation/ValidationGroups.java
package io.github.atengk.common.validation;
/**
* 参数校验分组
*
* @author Ateng
* @since 2026-05-05
*/
public final class ValidationGroups {
private ValidationGroups() {
}
/**
* 新增分组
*/
public interface Add {
}
/**
* 修改分组
*/
public interface Update {
}
/**
* 查询分组
*/
public interface Query {
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
分组 DTO:
文件位置:src/main/java/io/github/atengk/module/system/user/dto/UserSaveDTO.java
package io.github.atengk.module.system.user.dto;
import io.github.atengk.common.validation.ValidationGroups;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Null;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 用户保存参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserSaveDTO {
/**
* 用户 ID
*/
@Null(message = "新增时用户ID必须为空", groups = ValidationGroups.Add.class)
@NotNull(message = "修改时用户ID不能为空", groups = ValidationGroups.Update.class)
private Long id;
/**
* 用户名
*/
@NotBlank(message = "用户名不能为空", groups = ValidationGroups.Add.class)
@Size(max = 64, message = "用户名长度不能超过64个字符")
private String username;
/**
* 昵称
*/
@NotBlank(message = "昵称不能为空", groups = ValidationGroups.Add.class)
@Size(max = 64, message = "昵称长度不能超过64个字符")
private String nickname;
/**
* 手机号
*/
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
Controller 使用分组:
@PostMapping
public ApiResult<Long> addUser(@RequestBody @Validated(ValidationGroups.Add.class) UserSaveDTO dto) {
Long userId = userService.addUser(dto);
return ApiResult.success(userId);
}
@PutMapping("/{id}")
public ApiResult<Void> updateUser(
@PathVariable @NotNull(message = "用户ID不能为空") Long id,
@RequestBody @Validated(ValidationGroups.Update.class) UserSaveDTO dto) {
dto.setId(id);
userService.updateUser(dto);
return ApiResult.success();
}2
3
4
5
6
7
8
9
10
11
12
13
14
@Validated 支持指定校验分组,这也是它相对 @Valid 的主要扩展能力之一。(Home)
分组校验建议:
| 场景 | 建议 |
|---|---|
| 新增和修改规则差异小 | 可以使用分组 |
| 新增和修改字段差异大 | 建议拆成 AddDTO 和 UpdateDTO |
| 简单项目 | 拆 DTO 更清晰 |
| 公共参数 | 分组可以减少重复 |
| 复杂业务 | 不要把过多业务规则塞进分组 |
普通后台系统更推荐 AddDTO、UpdateDTO 分开,只有字段高度重合时再考虑分组校验。
嵌套对象校验
嵌套对象校验用于请求体中包含子对象或集合对象的场景,例如新增订单时同时提交订单明细。嵌套对象字段上必须加 @Valid,否则子对象内部约束不会被触发。
订单新增 DTO:
文件位置:src/main/java/io/github/atengk/module/order/dto/OrderAddDTO.java
package io.github.atengk.module.order.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 订单新增参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class OrderAddDTO {
/**
* 用户 ID
*/
@NotNull(message = "用户ID不能为空")
private Long userId;
/**
* 订单明细
*/
@Valid
@NotEmpty(message = "订单明细不能为空")
private List<OrderItemAddDTO> items;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
订单明细 DTO:
文件位置:src/main/java/io/github/atengk/module/order/dto/OrderItemAddDTO.java
package io.github.atengk.module.order.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 订单明细新增参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class OrderItemAddDTO {
/**
* 商品 ID
*/
@NotNull(message = "商品ID不能为空")
private Long productId;
/**
* 购买数量
*/
@NotNull(message = "购买数量不能为空")
@Min(value = 1, message = "购买数量不能小于1")
private Integer quantity;
/**
* 商品单价
*/
@NotNull(message = "商品单价不能为空")
@DecimalMin(value = "0.01", message = "商品单价必须大于0")
private BigDecimal price;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Controller:
@PostMapping("/orders")
public ApiResult<Long> addOrder(@RequestBody @Valid OrderAddDTO dto) {
Long orderId = orderService.addOrder(dto);
return ApiResult.success(orderId);
}2
3
4
5
嵌套校验建议:
| 场景 | 建议 |
|---|---|
| 子对象 | 字段上加 @Valid |
| 子集合 | List<@Valid XxxDTO> 或字段加 @Valid |
| 集合元素为空 | 使用 List<@NotNull Long> |
| 集合大小 | 使用 @Size(max = ...) |
| 明细金额 | 校验格式后仍需 Service 重新计算 |
| 商品价格 | 不信任前端价格,Service 从数据库读取 |
嵌套校验只能检查格式和基础约束。订单金额、库存、商品状态、会员折扣等必须在 Service 层校验和计算。
自定义校验注解
自定义校验注解适合复用性强、偏格式类或基础规则类校验,例如手机号、枚举值、金额精度、业务编码格式等。复杂业务规则不要写成注解,例如“订单当前状态是否允许取消”应放在 Service 层。
以枚举编码校验为例,先定义注解。
文件位置:src/main/java/io/github/atengk/common/validation/InEnum.java
package io.github.atengk.common.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* 枚举编码校验注解
*
* @author Ateng
* @since 2026-05-05
*/
@Documented
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Constraint(validatedBy = InEnumValidator.class)
public @interface InEnum {
/**
* 提示信息
*
* @return 提示信息
*/
String message() default "枚举值不合法";
/**
* 枚举类型
*
* @return 枚举类型
*/
Class<? extends Enum<?>> enumClass();
/**
* 枚举编码字段名
*
* @return 字段名
*/
String field() default "code";
/**
* 分组
*
* @return 分组
*/
Class<?>[] groups() default {};
/**
* 负载
*
* @return 负载
*/
Class<? extends Payload>[] payload() default {};
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
校验器:
文件位置:src/main/java/io/github/atengk/common/validation/InEnumValidator.java
package io.github.atengk.common.validation;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 枚举编码校验器
*
* @author Ateng
* @since 2026-05-05
*/
public class InEnumValidator implements ConstraintValidator<InEnum, Object> {
private Set<Object> allowedValues;
private String fieldName;
/**
* 初始化枚举允许值
*
* @param annotation 校验注解
*/
@Override
public void initialize(InEnum annotation) {
this.fieldName = annotation.field();
this.allowedValues = Arrays.stream(annotation.enumClass().getEnumConstants())
.map(item -> BeanUtil.getProperty(item, fieldName))
.collect(Collectors.toSet());
}
/**
* 校验参数是否合法
*
* @param value 参数值
* @param context 校验上下文
* @return 是否合法
*/
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if (ObjectUtil.isNull(value)) {
return true;
}
return allowedValues.contains(value);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
使用示例:
@InEnum(enumClass = UserStatusEnum.class, message = "用户状态不合法")
private Integer status;2
自定义手机号校验注解也可以这样实现,但对于简单正则,直接 @Pattern 即可。
自定义校验注解建议:
| 场景 | 是否适合 |
|---|---|
| 手机号格式 | 可以,或直接 @Pattern |
| 枚举编码 | 适合 |
| 金额精度 | 适合 |
| 日期范围格式 | 可以 |
| 数据库唯一性 | 不推荐,放 Service |
| 用户权限 | 不推荐,放权限服务 |
| 订单状态流转 | 不推荐,放业务服务 |
参数清洗
参数清洗用于在业务处理前去除前后空格、统一空字符串为 null、限制关键字长度、过滤不可见字符、规范化手机号等。参数清洗不是参数校验的替代品,它用于规范输入,校验用于判断输入是否符合规则。
简单清洗可以在 DTO 中提供方法,或在 Service 入库前处理。不要在 Controller 中散落大量清洗逻辑。
文件位置:src/main/java/io/github/atengk/module/system/user/support/UserParamCleaner.java
package io.github.atengk.module.system.user.support;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import org.springframework.stereotype.Component;
/**
* 用户参数清洗器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
public class UserParamCleaner {
/**
* 清洗新增参数
*
* @param dto 用户新增参数
*/
public void cleanAddDTO(UserAddDTO dto) {
dto.setUsername(StrUtil.trim(dto.getUsername()));
dto.setNickname(StrUtil.trim(dto.getNickname()));
dto.setPhone(StrUtil.trim(dto.getPhone()));
dto.setEmail(StrUtil.emptyToNull(StrUtil.trim(dto.getEmail())));
dto.setRemark(StrUtil.emptyToNull(StrUtil.trim(dto.getRemark())));
}
/**
* 清洗修改参数
*
* @param dto 用户修改参数
*/
public void cleanUpdateDTO(UserUpdateDTO dto) {
dto.setNickname(StrUtil.emptyToNull(StrUtil.trim(dto.getNickname())));
dto.setPhone(StrUtil.emptyToNull(StrUtil.trim(dto.getPhone())));
dto.setEmail(StrUtil.emptyToNull(StrUtil.trim(dto.getEmail())));
dto.setRemark(StrUtil.emptyToNull(StrUtil.trim(dto.getRemark())));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Service 使用:
@Transactional(rollbackFor = Exception.class)
public Long addUser(UserAddDTO dto) {
userParamCleaner.cleanAddDTO(dto);
this.checkUsernameUnique(dto.getUsername(), null);
this.checkPhoneUnique(dto.getPhone(), null);
UserEntity entity = userConvert.toEntity(dto);
this.save(entity);
log.info("新增用户成功,用户ID:{},用户名:{}", entity.getId(), entity.getUsername());
return entity.getId();
}2
3
4
5
6
7
8
9
10
11
12
13
参数清洗建议:
| 参数类型 | 建议 |
|---|---|
| 字符串 | 去除前后空格 |
| 可选字符串 | 空串转 null |
| 手机号 | 去空格,不做过度格式转换 |
| 邮箱 | 去空格,可统一小写 |
| 关键字 | 限制长度 |
| 排序字段 | 白名单映射 |
| 富文本 | 结合 XSS 处理 |
| SQL 片段 | 禁止接收 |
| 表名字段名 | 禁止前端直接传入 |
参数清洗不要擅自改变业务含义。例如用户昵称中的中间空格可能是有效内容,不应全部移除。
参数默认值处理
参数默认值用于处理前端未传字段时的默认业务值,例如分页默认第一页、默认每页 10 条、默认状态启用、默认排序值 0、默认金额 0。默认值可以放在 DTO 字段声明、Service 层补充、数据库默认值或自动填充中。
分页默认值适合放 DTO:
private Long pageNum = 1L;
private Long pageSize = 10L;
private String orderDirection = "desc";2
3
4
5
业务默认值适合放 Service:
private void fillAddDefaultValue(UserEntity entity) {
if (entity.getStatus() == null) {
entity.setStatus(UserStatusEnum.ENABLED);
}
if (entity.getBalanceAmount() == null) {
entity.setBalanceAmount(BigDecimal.ZERO);
}
if (entity.getSortOrder() == null) {
entity.setSortOrder(0);
}
}2
3
4
5
6
7
8
9
10
11
完整新增示例:
@Transactional(rollbackFor = Exception.class)
public Long addUser(UserAddDTO dto) {
userParamCleaner.cleanAddDTO(dto);
this.checkUsernameUnique(dto.getUsername(), null);
this.checkPhoneUnique(dto.getPhone(), null);
UserEntity entity = userConvert.toEntity(dto);
this.fillAddDefaultValue(entity);
boolean saved = this.save(entity);
if (!saved) {
throw new BusinessException("新增用户失败");
}
log.info("新增用户成功,用户ID:{}", entity.getId());
return entity.getId();
}
private void fillAddDefaultValue(UserEntity entity) {
if (entity.getStatus() == null) {
entity.setStatus(UserStatusEnum.ENABLED);
}
if (entity.getBalanceAmount() == null) {
entity.setBalanceAmount(BigDecimal.ZERO);
}
if (entity.getSortOrder() == null) {
entity.setSortOrder(0);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
数据库默认值示例:
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
balance_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '账户余额',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序'2
3
默认值处理建议:
| 默认值类型 | 推荐位置 |
|---|---|
| 分页页码 | DTO 字段默认值 |
| 分页条数 | DTO 字段默认值 |
| 排序方向 | DTO 字段默认值 |
| 创建时间 | 自动填充或数据库默认值 |
| 创建人 | 自动填充 |
| 租户 ID | 自动填充或租户插件 |
| 状态字段 | Service 层明确设置 |
| 金额字段 | Service 层明确设置,数据库兜底 |
| 排序字段 | Service 层明确设置,数据库兜底 |
| 逻辑删除 | 数据库默认值和 MyBatis-Plus 配置 |
参数校验、参数清洗、默认值处理的职责要分清:校验判断是否合法,清洗规范输入格式,默认值补齐业务缺省值。不要把业务状态流转、唯一性校验、库存校验、权限校验写进 DTO 注解里,这些应由 Service 层完成。
代码生成器
MyBatis-Plus 代码生成器用于根据数据库表结构快速生成 Entity、Mapper、Mapper XML、Service、ServiceImpl、Controller 等基础代码。MyBatis-Plus 3.5.1 起提供新的 FastAutoGenerator,使用 builder 模式进行配置;旧版 AutoGenerator 适用于 3.5.1 以下版本,不建议新项目继续使用。代码生成器依赖模板引擎,官方支持 Velocity、Freemarker、Beetl、Enjoy 等模板引擎,需要项目自行引入对应依赖。(MyBatis-Plus)
代码生成器适合生成基础骨架,不适合直接生成最终业务代码。生成后的代码仍然需要开发人员补充业务校验、数据权限、异常处理、事务边界、接口权限、对象转换、参数校验和单元测试。
FastAutoGenerator 使用
FastAutoGenerator 是新版代码生成器的推荐入口。常见生成流程包括:配置数据库连接、配置全局信息、配置包路径、配置生成策略、配置模板引擎,最后执行 execute()。官方示例中也采用 FastAutoGenerator.create(url, username, password) 加 builder 链式配置的方式生成代码。(MyBatis-Plus)
先引入生成器和模板引擎依赖。
文件位置:pom.xml
<!-- MyBatis-Plus 代码生成器 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Freemarker 模板引擎 -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
</dependency>
<!-- MySQL 驱动,用于代码生成器读取数据库表结构 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- Lombok,用于生成实体类 Getter、Setter 等代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
推荐版本属性:
<properties>
<mybatis-plus.version>3.5.15</mybatis-plus.version>
</properties>2
3
下面给出一个完整的代码生成器示例,适合 Spring Boot 3 + MyBatis-Plus + Lombok + OpenAPI 注解项目使用。
文件位置:src/test/java/io/github/atengk/generator/MyBatisPlusCodeGenerator.java
package io.github.atengk.generator;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.OutputFile;
import com.baomidou.mybatisplus.generator.config.rules.DateType;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import com.baomidou.mybatisplus.generator.fill.Column;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.List;
/**
* MyBatis-Plus 代码生成器
*
* @author Ateng
* @since 2026-05-05
*/
public class MyBatisPlusCodeGenerator {
private static final String JDBC_URL = "jdbc:mysql://127.0.0.1:3306/mybatis_plus_demo"
+ "?useUnicode=true"
+ "&characterEncoding=utf8"
+ "&serverTimezone=Asia/Shanghai"
+ "&useSSL=false"
+ "&allowPublicKeyRetrieval=true";
private static final String USERNAME = "root";
private static final String PASSWORD = "123456";
private static final String AUTHOR = "Ateng";
private static final String BASE_PACKAGE = "io.github.atengk";
private static final String MODULE_NAME = "system";
private static final List<String> TABLE_NAMES = List.of(
"sys_user",
"sys_role",
"sys_dept"
);
private static final List<String> TABLE_PREFIXES = List.of(
"sys_",
"biz_"
);
/**
* 执行代码生成
*
* @param args 启动参数
*/
public static void main(String[] args) {
String projectPath = Paths.get(System.getProperty("user.dir")).toString();
String javaOutputDir = projectPath + "/src/main/java";
String xmlOutputDir = projectPath + "/src/main/resources/mapper/" + MODULE_NAME;
FastAutoGenerator.create(JDBC_URL, USERNAME, PASSWORD)
.globalConfig(builder -> builder
.author(AUTHOR)
.commentDate("yyyy-MM-dd")
.dateType(DateType.TIME_PACK)
.disableOpenDir()
.outputDir(javaOutputDir)
)
.packageConfig(builder -> builder
.parent(BASE_PACKAGE)
.moduleName(MODULE_NAME)
.entity("entity")
.mapper("mapper")
.service("service")
.serviceImpl("service.impl")
.controller("controller")
.xml("mapper")
.pathInfo(Collections.singletonMap(OutputFile.xml, xmlOutputDir))
)
.strategyConfig(builder -> {
builder.addInclude(TABLE_NAMES)
.addTablePrefix(TABLE_PREFIXES);
builder.entityBuilder()
.superClass("io.github.atengk.common.entity.BaseEntity")
.addSuperEntityColumns(
"id",
"create_by",
"create_time",
"update_by",
"update_time",
"deleted",
"version"
)
.idType(IdType.ASSIGN_ID)
.enableLombok()
.enableTableFieldAnnotation()
.logicDeleteColumnName("deleted")
.logicDeletePropertyName("deleted")
.versionColumnName("version")
.versionPropertyName("version")
.addTableFills(
new Column("create_time", FieldFill.INSERT),
new Column("update_time", FieldFill.INSERT_UPDATE),
new Column("create_by", FieldFill.INSERT),
new Column("update_by", FieldFill.INSERT_UPDATE)
)
.formatFileName("%sEntity");
builder.mapperBuilder()
.enableBaseResultMap()
.enableBaseColumnList()
.formatMapperFileName("%sMapper")
.formatXmlFileName("%sMapper");
builder.serviceBuilder()
.formatServiceFileName("%sService")
.formatServiceImplFileName("%sServiceImpl");
builder.controllerBuilder()
.enableRestStyle()
.formatFileName("%sController");
})
.templateEngine(new FreemarkerTemplateEngine())
.execute();
}
/**
* 处理控制台输入的表名
*
* @param input 表名输入
* @return 表名列表
*/
private static List<String> getTableNames(String input) {
if (StrUtil.isBlank(input)) {
return TABLE_NAMES;
}
List<String> tableNames = StrUtil.split(input, ",")
.stream()
.map(StrUtil::trim)
.filter(StrUtil::isNotBlank)
.toList();
return CollUtil.isEmpty(tableNames) ? TABLE_NAMES : tableNames;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
生成器通常建议放在 src/test/java 或独立 generator 模块中,不要放到正式业务启动路径中。代码生成属于开发期工具,不应随生产应用启动。
数据库连接配置
数据库连接配置用于让代码生成器读取数据库表、字段、主键、注释、类型等元数据。FastAutoGenerator.create(url, username, password) 就是最常见的数据源配置入口;新版生成器也支持通过 dataSourceConfig 做类型转换、关键字处理等扩展。(MyBatis-Plus)
基础配置:
private static final String JDBC_URL = "jdbc:mysql://127.0.0.1:3306/mybatis_plus_demo"
+ "?useUnicode=true"
+ "&characterEncoding=utf8"
+ "&serverTimezone=Asia/Shanghai"
+ "&useSSL=false"
+ "&allowPublicKeyRetrieval=true";
private static final String USERNAME = "root";
private static final String PASSWORD = "123456";2
3
4
5
6
7
8
9
10
如果需要处理 MySQL tinyint 类型映射,可以配置类型转换。例如将 TINYINT 明确转为 Integer,避免误生成 Boolean。
.dataSourceConfig(builder -> builder
.typeConvertHandler((globalConfig, typeRegistry, metaInfo) -> {
if ("TINYINT".equalsIgnoreCase(metaInfo.getJdbcType().name())) {
return com.baomidou.mybatisplus.generator.config.rules.DbColumnType.INTEGER;
}
return typeRegistry.getColumnType(metaInfo);
})
)2
3
4
5
6
7
8
数据库连接配置建议:
| 配置项 | 建议 |
|---|---|
| 数据库地址 | 使用开发库或本地库,不直接连生产库 |
| 账号权限 | 只需要读取表结构权限 |
| 表注释 | 必须维护,生成类注释和接口说明会用到 |
| 字段注释 | 必须维护,生成字段注释会用到 |
| 字段类型 | 金额使用 DECIMAL,时间使用 DATETIME |
| 字符集 | 推荐 utf8mb4 |
| 时区 | JDBC URL 指定 serverTimezone=Asia/Shanghai |
代码生成高度依赖数据库注释质量。如果表注释、字段注释混乱,生成出来的代码注释也会混乱。
包名配置
包名配置决定生成代码放到哪个 Java 包下。新版生成器通过 packageConfig 设置父包、模块名、entity、mapper、service、serviceImpl、controller、xml 等路径;官方示例中也通过 parent、entity、mapper、service、serviceImpl、xml 配置生成位置。(MyBatis-Plus)
示例:
.packageConfig(builder -> builder
.parent("io.github.atengk")
.moduleName("system")
.entity("entity")
.mapper("mapper")
.service("service")
.serviceImpl("service.impl")
.controller("controller")
.xml("mapper")
.pathInfo(Collections.singletonMap(
OutputFile.xml,
projectPath + "/src/main/resources/mapper/system"
))
)2
3
4
5
6
7
8
9
10
11
12
13
14
生成后的推荐结构:
src/main/java/io/github/atengk/system
├── controller
│ └── UserController.java
├── entity
│ └── UserEntity.java
├── mapper
│ └── UserMapper.java
├── service
│ ├── UserService.java
│ └── impl
│ └── UserServiceImpl.java
src/main/resources/mapper/system
└── UserMapper.xml2
3
4
5
6
7
8
9
10
11
12
13
14
包名配置建议:
| 类型 | 推荐路径 |
|---|---|
| Entity | module/entity |
| Mapper | module/mapper |
| Mapper XML | resources/mapper/module |
| Service | module/service |
| ServiceImpl | module/service/impl |
| Controller | module/controller |
| DTO | 建议自定义模板生成到 module/dto |
| VO | 建议自定义模板生成到 module/vo |
| Query | 建议自定义模板生成到 module/query |
Entity 生成配置
Entity 生成配置决定实体类命名、父类、主键策略、Lombok、字段注解、逻辑删除、乐观锁、自动填充字段等。MyBatis-Plus 新版生成器的实体策略支持设置父类、Lombok、字段注解、文件覆盖、链式模型等配置;从官方配置文档看,实体策略中也支持设置父类、启用 Lombok、启用字段注解等能力。(MyBatis-Plus)
推荐配置:
builder.entityBuilder()
.superClass("io.github.atengk.common.entity.BaseEntity")
.addSuperEntityColumns(
"id",
"create_by",
"create_time",
"update_by",
"update_time",
"deleted",
"version"
)
.idType(IdType.ASSIGN_ID)
.enableLombok()
.enableTableFieldAnnotation()
.logicDeleteColumnName("deleted")
.logicDeletePropertyName("deleted")
.versionColumnName("version")
.versionPropertyName("version")
.addTableFills(
new Column("create_time", FieldFill.INSERT),
new Column("update_time", FieldFill.INSERT_UPDATE),
new Column("create_by", FieldFill.INSERT),
new Column("update_by", FieldFill.INSERT_UPDATE)
)
.formatFileName("%sEntity");2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
公共父类示例:
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 实体公共父类
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class BaseEntity implements Serializable {
/**
* 主键 ID
*/
@TableId
private Long id;
/**
* 创建人 ID
*/
@TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新人 ID
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 逻辑删除标识:0未删除,1已删除
*/
@TableLogic
private Integer deleted;
/**
* 乐观锁版本号
*/
@Version
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
Entity 生成建议:
| 配置项 | 建议 |
|---|---|
| 命名 | %sEntity |
| Lombok | 开启 |
| 字段注解 | 开启 @TableField |
| 主键策略 | 默认 ASSIGN_ID |
| 公共字段 | 放入 BaseEntity |
| 公共字段生成 | 使用 addSuperEntityColumns 排除 |
| 逻辑删除 | 统一 deleted |
| 乐观锁 | 统一 version |
| 时间类型 | 使用 LocalDateTime |
Mapper 生成配置
Mapper 生成配置用于控制 Mapper 接口、ResultMap、BaseColumnList 和文件命名。普通 MyBatis-Plus 项目中,Mapper 继承 BaseMapper<Entity> 即可。
推荐配置:
builder.mapperBuilder()
.enableBaseResultMap()
.enableBaseColumnList()
.formatMapperFileName("%sMapper")
.formatXmlFileName("%sMapper");2
3
4
5
生成后的 Mapper 示例:
package io.github.atengk.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.atengk.system.entity.UserEntity;
/**
* 用户 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserMapper extends BaseMapper<UserEntity> {
}2
3
4
5
6
7
8
9
10
11
12
13
Mapper 生成建议:
| 配置项 | 建议 |
|---|---|
| 父接口 | BaseMapper<Entity> |
| Mapper 命名 | %sMapper |
| XML 命名 | %sMapper.xml |
| BaseResultMap | 建议开启 |
| BaseColumnList | 建议开启 |
| 自定义方法 | 生成后手动补充 |
| 注解 SQL | 不建议由生成器大量生成 |
BaseResultMap 和 BaseColumnList 对后续 XML 自定义 SQL 有帮助,建议生成。
Service 生成配置
Service 生成配置用于生成 Service 接口和 ServiceImpl 实现类。普通项目中,Service 接口继承 IService<Entity>,ServiceImpl 继承 ServiceImpl<Mapper, Entity>。
推荐配置:
builder.serviceBuilder()
.formatServiceFileName("%sService")
.formatServiceImplFileName("%sServiceImpl");2
3
生成后的 Service:
package io.github.atengk.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.github.atengk.system.entity.UserEntity;
/**
* 用户服务接口
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserService extends IService<UserEntity> {
}2
3
4
5
6
7
8
9
10
11
12
13
生成后的 ServiceImpl:
package io.github.atengk.system.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.system.entity.UserEntity;
import io.github.atengk.system.mapper.UserMapper;
import io.github.atengk.system.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 用户服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService {
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Service 生成建议:
| 配置项 | 建议 |
|---|---|
| Service 命名 | %sService |
| ServiceImpl 命名 | %sServiceImpl |
| 业务方法 | 生成后手动补充 |
| 事务注解 | 不建议模板默认加到所有方法 |
| 日志注解 | ServiceImpl 可加 @Slf4j |
| 接口返回 | 不直接返回 Entity 给 Controller |
生成器只生成基础服务骨架,业务方法需要按具体场景补充 DTO、VO、事务、校验和异常处理。
Controller 生成配置
Controller 生成配置用于生成 REST 接口控制器。官方新版生成器支持 controllerBuilder(),并可开启 REST 风格。(MyBatis-Plus)
推荐配置:
builder.controllerBuilder()
.enableRestStyle()
.formatFileName("%sController");2
3
生成后的基础 Controller 不建议直接暴露 MyBatis-Plus 的 Entity 和 Service CRUD。推荐改造成项目统一风格:
package io.github.atengk.system.controller;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.system.dto.UserAddDTO;
import io.github.atengk.system.dto.UserUpdateDTO;
import io.github.atengk.system.query.UserPageQuery;
import io.github.atengk.system.service.UserService;
import io.github.atengk.system.vo.UserDetailVO;
import io.github.atengk.system.vo.UserPageVO;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* 用户接口
*
* @author Ateng
* @since 2026-05-05
*/
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
/**
* 新增用户
*
* @param dto 用户新增参数
* @return 用户 ID
*/
@PostMapping
public ApiResult<Long> addUser(@RequestBody @Valid UserAddDTO dto) {
Long userId = userService.addUser(dto);
return ApiResult.success(userId);
}
/**
* 修改用户
*
* @param id 用户 ID
* @param dto 用户修改参数
* @return 空响应
*/
@PutMapping("/{id}")
public ApiResult<Void> updateUser(
@PathVariable @NotNull(message = "用户ID不能为空") Long id,
@RequestBody @Valid UserUpdateDTO dto) {
dto.setId(id);
userService.updateUser(dto);
return ApiResult.success();
}
/**
* 查询用户详情
*
* @param id 用户 ID
* @return 用户详情
*/
@GetMapping("/{id}")
public ApiResult<UserDetailVO> getUserDetail(
@PathVariable @NotNull(message = "用户ID不能为空") Long id) {
UserDetailVO detailVO = userService.getUserDetail(id);
return ApiResult.success(detailVO);
}
/**
* 分页查询用户
*
* @param query 用户分页查询参数
* @return 用户分页结果
*/
@GetMapping("/page")
public ApiResult<PageResult<UserPageVO>> pageUser(@Valid UserPageQuery query) {
PageResult<UserPageVO> pageResult = userService.pageUser(query);
return ApiResult.success(pageResult);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
Controller 生成建议:
| 规范项 | 建议 |
|---|---|
| REST 风格 | 开启 |
| 返回结构 | 使用 ApiResult |
| 分页返回 | 使用 PageResult |
| 入参 | 使用 DTO / Query |
| 出参 | 使用 VO |
| 参数校验 | 使用 @Valid、@Validated |
| Entity 暴露 | 不建议 |
| 通用 CRUD | 生成后按业务裁剪 |
XML 生成配置
XML 生成配置用于生成 Mapper XML 文件。MyBatis-Plus 新版生成器可以通过 pathInfo 指定 XML 输出目录;官方示例中也通过 OutputFile.xml 设置 Mapper XML 生成路径。(MyBatis-Plus)
推荐配置:
.packageConfig(builder -> builder
.parent(BASE_PACKAGE)
.moduleName(MODULE_NAME)
.xml("mapper")
.pathInfo(Collections.singletonMap(
OutputFile.xml,
projectPath + "/src/main/resources/mapper/" + MODULE_NAME
))
)2
3
4
5
6
7
8
9
生成后的 XML 示例:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.atengk.system.mapper.UserMapper">
<resultMap id="BaseResultMap" type="io.github.atengk.system.entity.UserEntity">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="nickname" property="nickname"/>
<result column="phone" property="phone"/>
<result column="status" property="status"/>
<result column="create_time" property="createTime"/>
<result column="update_time" property="updateTime"/>
</resultMap>
<sql id="Base_Column_List">
id,
username,
nickname,
phone,
status,
create_time,
update_time
</sql>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
XML 生成建议:
| 配置项 | 建议 |
|---|---|
| 输出目录 | src/main/resources/mapper/{module} |
| ResultMap | 建议开启 |
| BaseColumnList | 建议开启 |
| 自定义 SQL | 生成后手动补充 |
| SQL 片段 | 可保留基础字段列表 |
| 二级缓存 | 不建议默认开启 |
模板自定义
模板自定义用于生成符合项目规范的代码,例如类注释、Lombok 注解、OpenAPI 注解、统一返回结构、DTO/VO/Query、字段填充、逻辑删除、乐观锁等。官方新版生成器支持自定义模板,也支持自定义 DTO、VO 等模板;从 3.5.6 起,模板配置迁移到 StrategyConfig 相关 builder 中。(MyBatis-Plus)
推荐模板目录:
src/test/resources/templates
├── entity.java.ftl
├── mapper.java.ftl
├── mapper.xml.ftl
├── service.java.ftl
├── serviceImpl.java.ftl
├── controller.java.ftl
├── dto.java.ftl
├── query.java.ftl
└── vo.java.ftl2
3
4
5
6
7
8
9
10
配置自定义模板:
.strategyConfig(builder -> {
builder.entityBuilder()
.javaTemplate("/templates/entity.java");
builder.mapperBuilder()
.mapperTemplate("/templates/mapper.java")
.mapperXmlTemplate("/templates/mapper.xml");
builder.serviceBuilder()
.serviceTemplate("/templates/service.java")
.serviceImplTemplate("/templates/serviceImpl.java");
builder.controllerBuilder()
.template("/templates/controller.java");
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
自定义 DTO、VO、Query 可以通过 injectionConfig 增加自定义输出文件。官方配置说明中也提到 InjectionConfig 可用于自定义输出文件、自定义 Map 参数和自定义模板文件配置。(MyBatis-Plus)
自定义 DTO 输出示例:
.injectionConfig(builder -> builder
.customMap(Collections.singletonMap("moduleName", MODULE_NAME))
.customFile(customFile -> customFile
.fileName("AddDTO.java")
.templatePath("/templates/addDTO.java.ftl")
.packageName("dto")
)
)2
3
4
5
6
7
8
模板自定义建议:
| 模板 | 建议 |
|---|---|
| Entity | 必须统一 |
| Mapper | 可轻量自定义 |
| XML | 建议保留 BaseResultMap 和 BaseColumnList |
| Service | 只生成接口骨架 |
| ServiceImpl | 加 @Slf4j、@Service |
| Controller | 按项目统一响应结构生成 |
| DTO | 根据新增、修改分别生成 |
| Query | 继承 PageQuery |
| VO | 分 PageVO、DetailVO |
| Convert | 可额外生成 MapStruct 转换器 |
Lombok 模板
Lombok 模板用于生成简洁实体类和 DTO、VO。实体类建议使用 @Getter、@Setter,不建议默认使用 @Data,因为 @Data 会生成 equals、hashCode、toString,在实体关联、敏感字段和大字段场景中可能产生不必要问题。
Entity 模板片段:
package ${package.Entity};
<#list table.importPackages as pkg>
import ${pkg};
</#list>
import lombok.Getter;
import lombok.Setter;
/**
* ${table.comment!table.entityName}
*
* @author ${author}
* @since ${date}
*/
@Getter
@Setter
@TableName("${table.name}")
public class ${entity} extends BaseEntity {
<#list table.fields as field>
/**
* ${field.comment}
*/
<#if field.keyFlag>
@TableId(type = IdType.ASSIGN_ID)
</#if>
<#if field.convert>
@TableField("${field.name}")
</#if>
private ${field.propertyType} ${field.propertyName};
</#list>
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
DTO 模板片段:
package ${package.Parent}.${package.ModuleName}.dto;
import lombok.Getter;
import lombok.Setter;
/**
* ${table.comment!table.entityName}新增参数
*
* @author ${author}
* @since ${date}
*/
@Getter
@Setter
public class ${table.entityName}AddDTO {
<#list table.fields as field>
<#if !field.keyFlag && field.propertyName != "deleted" && field.propertyName != "version">
/**
* ${field.comment}
*/
private ${field.propertyType} ${field.propertyName};
</#if>
</#list>
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Lombok 模板建议:
| 类型 | 推荐注解 |
|---|---|
| Entity | @Getter、@Setter |
| DTO | @Getter、@Setter |
| VO | @Getter、@Setter |
| Query | @Getter、@Setter |
| ServiceImpl | @Slf4j |
| 构造注入类 | @RequiredArgsConstructor |
| Entity | 不建议默认 @Data |
| 敏感对象 | 谨慎生成 toString |
Swagger 注解模板
Spring Boot 3 项目一般使用 OpenAPI 3 注解,例如 @Schema,而不是旧 Swagger 2 的 @ApiModel、@ApiModelProperty。MyBatis-Plus 生成器提供 enableSwagger() 能力,但不同项目依赖的文档框架可能不同,因此更建议通过自定义模板统一为 @Schema。
VO 模板片段:
package ${package.Parent}.${package.ModuleName}.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
/**
* ${table.comment!table.entityName}分页返回对象
*
* @author ${author}
* @since ${date}
*/
@Getter
@Setter
@Schema(description = "${table.comment!table.entityName}分页返回对象")
public class ${table.entityName}PageVO {
<#list table.fields as field>
<#if field.propertyName != "deleted" && field.propertyName != "version">
/**
* ${field.comment}
*/
@Schema(description = "${field.comment}")
private ${field.propertyType} ${field.propertyName};
</#if>
</#list>
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
DTO 模板片段:
package ${package.Parent}.${package.ModuleName}.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
/**
* ${table.comment!table.entityName}新增参数
*
* @author ${author}
* @since ${date}
*/
@Getter
@Setter
@Schema(description = "${table.comment!table.entityName}新增参数")
public class ${table.entityName}AddDTO {
<#list table.fields as field>
<#if !field.keyFlag && field.propertyName != "createBy" && field.propertyName != "createTime"
&& field.propertyName != "updateBy" && field.propertyName != "updateTime"
&& field.propertyName != "deleted" && field.propertyName != "version">
/**
* ${field.comment}
*/
@Schema(description = "${field.comment}")
private ${field.propertyType} ${field.propertyName};
</#if>
</#list>
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Swagger / OpenAPI 模板建议:
| 项目类型 | 建议 |
|---|---|
| Spring Boot 3 | 使用 io.swagger.v3.oas.annotations.media.Schema |
| Knife4j + SpringDoc | 使用 OpenAPI 3 注解 |
| 老项目 Swagger 2 | 才考虑 @ApiModelProperty |
| Entity | 可不加接口文档注解 |
| DTO / VO | 建议加 @Schema |
| Controller | 可加 @Operation、@Tag |
| 字段注释 | 依赖数据库字段注释生成 |
字段填充模板
字段填充模板用于生成 @TableField(fill = FieldFill.INSERT) 和 @TableField(fill = FieldFill.INSERT_UPDATE)。如果公共字段已经放到 BaseEntity 并通过 addSuperEntityColumns 排除了,普通实体模板中就不需要重复生成这些字段。
如果没有公共父类,则模板中可以按字段名生成填充注解:
<#if field.name == "create_time">
@TableField(value = "create_time", fill = FieldFill.INSERT)
<#elseif field.name == "update_time">
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
<#elseif field.name == "create_by">
@TableField(value = "create_by", fill = FieldFill.INSERT)
<#elseif field.name == "update_by">
@TableField(value = "update_by", fill = FieldFill.INSERT_UPDATE)
<#elseif field.convert>
@TableField("${field.name}")
</#if>
private ${field.propertyType} ${field.propertyName};2
3
4
5
6
7
8
9
10
11
12
生成器策略配置中也可以声明填充字段:
.addTableFills(
new Column("create_time", FieldFill.INSERT),
new Column("update_time", FieldFill.INSERT_UPDATE),
new Column("create_by", FieldFill.INSERT),
new Column("update_by", FieldFill.INSERT_UPDATE)
)2
3
4
5
6
字段填充模板建议:
| 字段 | 填充策略 |
|---|---|
create_time | FieldFill.INSERT |
update_time | FieldFill.INSERT_UPDATE |
create_by | FieldFill.INSERT |
update_by | FieldFill.INSERT_UPDATE |
tenant_id | FieldFill.INSERT |
dept_id | FieldFill.INSERT |
| 业务状态 | 不使用自动填充 |
| 金额字段 | 不使用自动填充 |
公共字段更推荐放在 BaseEntity 中,避免每个生成实体重复这些字段。
逻辑删除模板
逻辑删除字段通常为 deleted。如果使用公共父类,deleted 应放在 BaseEntity 中,并通过 addSuperEntityColumns("deleted") 排除生成。否则可以在实体模板中按字段名生成 @TableLogic。
策略配置:
.entityBuilder()
.logicDeleteColumnName("deleted")
.logicDeletePropertyName("deleted")2
3
实体模板片段:
<#if field.name == "deleted">
@TableLogic(value = "0", delval = "1")
<#elseif field.convert>
@TableField("${field.name}")
</#if>
private ${field.propertyType} ${field.propertyName};2
3
4
5
6
全局配置建议:
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 02
3
4
5
6
逻辑删除模板建议:
| 方案 | 建议 |
|---|---|
有 BaseEntity | 逻辑删除字段放父类 |
| 无父类 | 模板生成 @TableLogic |
| 删除字段名 | 统一 deleted |
| 未删除值 | 0 |
| 已删除值 | 1 |
| 唯一索引冲突 | 生成器不能自动解决,需要表设计处理 |
代码生成规范
代码生成器是提效工具,不是架构边界。生成代码后必须按项目规范检查,不应直接把生成结果不经审查提交到主分支。
推荐生成流程:
- 先设计并审查数据库表结构。
- 确保表注释、字段注释、字段类型、默认值、索引完整。
- 运行代码生成器生成基础代码。
- 检查 Entity、Mapper、XML、Service、Controller。
- 补充 DTO、VO、Query、Convert。
- 补充参数校验、业务校验、异常处理、事务。
- 补充数据权限、租户隔离、接口权限。
- 补充单元测试和接口文档。
- 代码评审后提交。
代码生成规范清单:
| 检查项 | 要求 |
|---|---|
| 作者 | 统一 @author Ateng |
| 日期 | 统一 @since yyyy-MM-dd |
| Entity | 不暴露给 Controller |
| DTO | 新增、修改分开 |
| VO | 列表、详情分开 |
| Query | 分页查询继承 PageQuery |
| Mapper | 只放数据库访问 |
| XML | 不写 SELECT * |
| Service | 补充事务和业务校验 |
| Controller | 使用统一返回结构 |
| 参数校验 | DTO 上补充 Jakarta Validation |
| 对象转换 | 使用 MapStruct 或 BeanUtil |
| 日志 | Service 关键写操作记录中文日志 |
| 权限 | 管理接口补充权限校验 |
| 数据权限 | 查询、详情、修改、删除都要覆盖 |
| 代码覆盖 | 不建议生成器直接覆盖手写业务代码 |
建议生成代码时输出到临时目录,再由开发人员选择性合并到正式目录。可以通过 enableFileOverride() 开启覆盖,但只建议在临时代码生成目录使用,不建议对正式业务目录直接覆盖。
临时输出目录示例:
String generatorOutputDir = projectPath + "/target/generated-sources/mybatis-plus";
.globalConfig(builder -> builder
.author("Ateng")
.commentDate("yyyy-MM-dd")
.disableOpenDir()
.outputDir(generatorOutputDir)
)2
3
4
5
6
7
8
正式项目中,代码生成器推荐遵循一个原则:只生成稳定、重复、规范化的骨架代码;业务规则、权限控制、事务边界、安全控制、复杂 SQL 和性能优化必须由开发人员明确设计。
通用基础能力封装
通用基础能力封装用于沉淀项目中重复出现的 Controller、Service、Mapper、Entity、分页、响应、树结构、字典回显、当前用户上下文、操作日志等基础能力。封装目标是减少重复代码,但不能把业务规则强行塞进基础类。基础能力应保持稳定、轻量、通用,复杂业务逻辑仍然放在具体业务模块中。
BaseController
BaseController 用于封装 Controller 层常用的成功响应、分页响应、空响应、当前用户获取等基础方法。Controller 不建议继承过重的父类,基础方法应保持简单。
BaseController 提供统一响应封装和当前用户快捷获取能力。
package io.github.atengk.common.web;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import jakarta.annotation.Resource;
/**
* Controller 基础父类
*
* @author Ateng
* @since 2026-05-05
*/
public abstract class BaseController {
@Resource
protected CurrentUserContext currentUserContext;
/**
* 成功响应
*
* @param data 响应数据
* @param <T> 数据类型
* @return 统一响应
*/
protected <T> ApiResult<T> success(T data) {
return ApiResult.success(data);
}
/**
* 成功空响应
*
* @return 统一响应
*/
protected ApiResult<Void> success() {
return ApiResult.success();
}
/**
* 分页响应
*
* @param pageResult 分页结果
* @param <T> 数据类型
* @return 统一响应
*/
protected <T> ApiResult<PageResult<T>> page(PageResult<T> pageResult) {
return ApiResult.success(pageResult);
}
/**
* 获取当前登录用户
*
* @return 当前登录用户
*/
protected LoginUser getLoginUser() {
return currentUserContext.getLoginUserOrDefault();
}
/**
* 获取当前用户 ID
*
* @return 当前用户 ID
*/
protected Long getUserId() {
return getLoginUser().getUserId();
}
/**
* 获取当前租户 ID
*
* @return 当前租户 ID
*/
protected Long getTenantId() {
return getLoginUser().getTenantId();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
Controller 使用示例:
package io.github.atengk.module.system.user.controller;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.common.web.BaseController;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.service.UserService;
import io.github.atengk.module.system.user.vo.UserPageVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* 用户接口
*
* @author Ateng
* @since 2026-05-05
*/
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController extends BaseController {
private final UserService userService;
/**
* 新增用户
*
* @param dto 用户新增参数
* @return 用户 ID
*/
@PostMapping
public ApiResult<Long> addUser(@RequestBody @Valid UserAddDTO dto) {
return success(userService.addUser(dto));
}
/**
* 分页查询用户
*
* @param query 用户分页查询参数
* @return 用户分页结果
*/
@GetMapping("/page")
public ApiResult<PageResult<UserPageVO>> pageUser(@Valid UserPageQuery query) {
return page(userService.pageUser(query));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
BaseController 不建议封装具体业务操作,例如新增、修改、删除模板方法。Controller 层应保持请求接入职责,业务流程交给 Service。
BaseService
BaseService 用于扩展 MyBatis-Plus 的 IService,提供常用的必查、保存校验、更新校验、删除校验、存在性判断等基础方法。
BaseService 接口定义通用业务方法,具体实现由 BaseServiceImpl 提供。
package io.github.atengk.common.service;
import com.baomidou.mybatisplus.extension.service.IService;
import java.io.Serializable;
import java.util.Collection;
/**
* Service 基础接口
*
* @author Ateng
* @since 2026-05-05
*/
public interface BaseService<T> extends IService<T> {
/**
* 根据 ID 查询数据,不存在时抛出异常
*
* @param id 主键 ID
* @return 实体对象
*/
T getRequiredById(Serializable id);
/**
* 保存数据,失败时抛出异常
*
* @param entity 实体对象
*/
void saveRequired(T entity);
/**
* 根据 ID 修改数据,失败时抛出异常
*
* @param entity 实体对象
*/
void updateByIdRequired(T entity);
/**
* 根据 ID 删除数据,失败时抛出异常
*
* @param id 主键 ID
*/
void removeByIdRequired(Serializable id);
/**
* 批量删除数据,失败时抛出异常
*
* @param ids 主键 ID 集合
*/
void removeBatchByIdsRequired(Collection<? extends Serializable> ids);
/**
* 根据 ID 判断数据是否存在
*
* @param id 主键 ID
* @return 是否存在
*/
boolean existsById(Serializable id);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
BaseServiceImpl 实现通用校验逻辑,减少业务 Service 中重复判断保存失败、修改失败、数据不存在等代码。
package io.github.atengk.common.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.common.result.ResultCode;
import io.github.atengk.common.service.BaseService;
import lombok.extern.slf4j.Slf4j;
import java.io.Serializable;
import java.util.Collection;
/**
* Service 基础实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
public abstract class BaseServiceImpl<M extends BaseMapper<T>, T>
extends ServiceImpl<M, T>
implements BaseService<T> {
/**
* 根据 ID 查询数据,不存在时抛出异常
*
* @param id 主键 ID
* @return 实体对象
*/
@Override
public T getRequiredById(Serializable id) {
if (ObjectUtil.isNull(id)) {
throw new BusinessException("主键ID不能为空");
}
T entity = this.getById(id);
if (ObjectUtil.isNull(entity)) {
throw new BusinessException(ResultCode.DATA_NOT_FOUND, "数据不存在");
}
return entity;
}
/**
* 保存数据,失败时抛出异常
*
* @param entity 实体对象
*/
@Override
public void saveRequired(T entity) {
if (ObjectUtil.isNull(entity)) {
throw new BusinessException("保存对象不能为空");
}
boolean saved = this.save(entity);
if (!saved) {
throw new BusinessException("保存数据失败");
}
}
/**
* 根据 ID 修改数据,失败时抛出异常
*
* @param entity 实体对象
*/
@Override
public void updateByIdRequired(T entity) {
if (ObjectUtil.isNull(entity)) {
throw new BusinessException("修改对象不能为空");
}
boolean updated = this.updateById(entity);
if (!updated) {
throw new BusinessException("修改数据失败,请刷新后重试");
}
}
/**
* 根据 ID 删除数据,失败时抛出异常
*
* @param id 主键 ID
*/
@Override
public void removeByIdRequired(Serializable id) {
if (ObjectUtil.isNull(id)) {
throw new BusinessException("主键ID不能为空");
}
boolean removed = this.removeById(id);
if (!removed) {
throw new BusinessException("删除数据失败或数据不存在");
}
}
/**
* 批量删除数据,失败时抛出异常
*
* @param ids 主键 ID 集合
*/
@Override
public void removeBatchByIdsRequired(Collection<? extends Serializable> ids) {
if (CollUtil.isEmpty(ids)) {
throw new BusinessException("主键ID列表不能为空");
}
boolean removed = this.removeBatchByIds(ids, 1000);
if (!removed) {
throw new BusinessException("批量删除数据失败");
}
}
/**
* 根据 ID 判断数据是否存在
*
* @param id 主键 ID
* @return 是否存在
*/
@Override
public boolean existsById(Serializable id) {
if (ObjectUtil.isNull(id)) {
return false;
}
return ObjectUtil.isNotNull(this.getById(id));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
业务 Service 继承示例:
package io.github.atengk.module.system.user.service.impl;
import io.github.atengk.common.service.impl.BaseServiceImpl;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import io.github.atengk.module.system.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 用户服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class UserServiceImpl extends BaseServiceImpl<UserMapper, UserEntity> implements UserService {
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
BaseService 只封装稳定通用动作,不建议封装“新增前校验唯一性”“删除前校验引用”等业务规则。
BaseMapper 扩展
MyBatis-Plus 已经提供 BaseMapper<T>。如果项目需要所有 Mapper 统一扩展一些通用方法,可以定义自己的 BaseMapperX<T>。但扩展 Mapper 要谨慎,避免加入无法适配所有表的方法。
BaseMapperX 提供轻量增强方法,例如根据 ID 必查、根据条件查询第一条。
package io.github.atengk.common.mapper;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.toolkit.Db;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.common.result.ResultCode;
import java.io.Serializable;
import java.util.List;
/**
* Mapper 基础扩展接口
*
* @author Ateng
* @since 2026-05-05
*/
public interface BaseMapperX<T> extends BaseMapper<T> {
/**
* 根据 ID 查询数据,不存在时抛出异常
*
* @param id 主键 ID
* @return 实体对象
*/
default T selectRequiredById(Serializable id) {
T entity = this.selectById(id);
if (entity == null) {
throw new BusinessException(ResultCode.DATA_NOT_FOUND, "数据不存在");
}
return entity;
}
/**
* 根据条件查询第一条
*
* @param wrapper 查询条件
* @return 第一条数据
*/
default T selectFirst(Wrapper<T> wrapper) {
List<T> records = this.selectList(wrapper);
if (CollUtil.isEmpty(records)) {
return null;
}
return records.get(0);
}
/**
* 判断指定 ID 是否存在
*
* @param id 主键 ID
* @return 是否存在
*/
default boolean existsById(Serializable id) {
return this.selectById(id) != null;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
业务 Mapper 使用:
package io.github.atengk.module.system.user.mapper;
import io.github.atengk.common.mapper.BaseMapperX;
import io.github.atengk.module.system.user.entity.UserEntity;
/**
* 用户 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserMapper extends BaseMapperX<UserEntity> {
}2
3
4
5
6
7
8
9
10
11
12
13
Mapper 扩展建议:
| 项目 | 建议 |
|---|---|
| 通用查询 | 可以封装 |
| 必查方法 | 可以封装 |
| 复杂业务 SQL | 不放基础 Mapper |
| 多表 JOIN | 放具体 Mapper |
| 数据权限 | 不放 Mapper 默认方法中 |
| 事务 | 不在 Mapper 层处理 |
| 异常语义 | 简单通用异常可以封装 |
BaseEntity
BaseEntity 用于承载主键、审计字段、逻辑删除、乐观锁等所有业务实体通用字段。建议业务表统一继承,代码生成器中通过 addSuperEntityColumns 排除重复字段。
BaseEntity 封装通用实体字段。
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 实体公共父类
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class BaseEntity implements Serializable {
/**
* 主键 ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 创建人 ID
*/
@TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新人 ID
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 逻辑删除标识:0未删除,1已删除
*/
@TableLogic(value = "0", delval = "1")
private Integer deleted;
/**
* 乐观锁版本号
*/
@Version
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
多租户实体父类:
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Getter;
import lombok.Setter;
/**
* 租户实体公共父类
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class TenantBaseEntity extends BaseEntity {
/**
* 租户 ID
*/
@TableField(fill = FieldFill.INSERT)
private Long tenantId;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
部门归属实体父类:
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Getter;
import lombok.Setter;
/**
* 部门实体公共父类
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class DeptBaseEntity extends TenantBaseEntity {
/**
* 部门 ID
*/
@TableField(fill = FieldFill.INSERT)
private Long deptId;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
BaseEntity 建议:
| 字段 | 是否建议放入 |
|---|---|
id | 建议 |
createBy | 建议 |
createTime | 建议 |
updateBy | 建议 |
updateTime | 建议 |
deleted | 建议 |
version | 可按项目决定 |
tenantId | 建议放到单独租户父类 |
deptId | 建议放到单独部门父类 |
| 业务状态 | 不建议 |
| 备注字段 | 不建议所有表强制统一 |
BaseQuery
BaseQuery 用于封装查询对象的通用字段,例如关键字、时间范围、状态、排序字段等。项目中也可以让 PageParam 继承它。
BaseQuery 提供常见查询参数和时间范围校验方法。
package io.github.atengk.common.query;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.exception.BusinessException;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 查询参数基础对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class BaseQuery {
/**
* 关键字
*/
@Size(max = 100, message = "关键字长度不能超过100个字符")
private String keyword;
/**
* 状态
*/
private Integer status;
/**
* 开始时间
*/
private LocalDateTime startTime;
/**
* 结束时间
*/
private LocalDateTime endTime;
/**
* 排序字段
*/
private String orderBy;
/**
* 排序方向:asc 或 desc
*/
private String orderDirection = "desc";
/**
* 获取安全排序方向
*
* @return 排序方向
*/
public String getSafeOrderDirection() {
return StrUtil.equalsIgnoreCase(orderDirection, "asc") ? "asc" : "desc";
}
/**
* 清洗查询参数
*/
public void clean() {
this.keyword = StrUtil.emptyToNull(StrUtil.trim(keyword));
this.orderBy = StrUtil.emptyToNull(StrUtil.trim(orderBy));
this.orderDirection = getSafeOrderDirection();
}
/**
* 校验时间范围
*/
public void validateTimeRange() {
if (ObjectUtil.isNotNull(startTime)
&& ObjectUtil.isNotNull(endTime)
&& startTime.isAfter(endTime)) {
throw new BusinessException("开始时间不能大于结束时间");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
业务 Query 示例:
package io.github.atengk.module.system.user.query;
import io.github.atengk.common.query.PageParam;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 用户分页查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserPageQuery extends PageParam {
/**
* 用户名
*/
@Size(max = 64, message = "用户名长度不能超过64个字符")
private String username;
/**
* 手机号
*/
@Size(max = 20, message = "手机号长度不能超过20个字符")
private String phone;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
PageParam
PageParam 用于统一分页请求参数。建议所有分页查询对象都继承它,并通过校验注解限制页码和页大小。
PageParam 提供 MyBatis-Plus Page 对象转换能力。
package io.github.atengk.common.query;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.Setter;
/**
* 分页请求参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class PageParam extends BaseQuery {
/**
* 当前页
*/
@Min(value = 1, message = "当前页不能小于1")
private Long pageNum = 1L;
/**
* 每页条数
*/
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 200, message = "每页条数不能超过200")
private Long pageSize = 10L;
/**
* 转换为 MyBatis-Plus 分页对象
*
* @param <T> 数据类型
* @return 分页对象
*/
public <T> Page<T> toPage() {
return Page.of(pageNum, pageSize);
}
/**
* 转换为不查询总数的 MyBatis-Plus 分页对象
*
* @param <T> 数据类型
* @return 分页对象
*/
public <T> Page<T> toPageWithoutCount() {
Page<T> page = Page.of(pageNum, pageSize);
page.setSearchCount(false);
return page;
}
/**
* 获取偏移量
*
* @return 偏移量
*/
public Long getOffset() {
return (pageNum - 1) * pageSize;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
使用示例:
public PageResult<UserPageVO> pageUser(UserPageQuery query) {
query.clean();
query.validateTimeRange();
Page<UserEntity> page = query.toPage();
Page<UserEntity> result = this.lambdaQuery()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(StrUtil.isNotBlank(query.getPhone()), UserEntity::getPhone, query.getPhone())
.orderByDesc(UserEntity::getCreateTime)
.page(page);
return PageResult.of(result.convert(userConvert::toPageVO));
}2
3
4
5
6
7
8
9
10
11
12
13
14
分页参数建议:
| 参数 | 建议 |
|---|---|
pageNum | 默认 1 |
pageSize | 默认 10 |
| 最大值 | 后台列表建议 100 或 200 |
| 排序字段 | 不在 PageParam 中直接拼 SQL |
| 深分页 | Service 层额外限制最大 offset |
| 移动端滚动分页 | 可单独设计 CursorParam |
PageResult
PageResult 用于统一分页响应结构,避免直接向前端暴露 MyBatis-Plus 的 Page 内部字段。
PageResult 支持从 IPage 快速转换。
package io.github.atengk.common.vo;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
/**
* 通用分页返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class PageResult<T> implements Serializable {
/**
* 当前页
*/
private Long pageNum;
/**
* 每页条数
*/
private Long pageSize;
/**
* 总条数
*/
private Long total;
/**
* 总页数
*/
private Long pages;
/**
* 数据列表
*/
private List<T> records;
/**
* 创建分页结果
*
* @param pageNum 当前页
* @param pageSize 每页条数
* @param total 总条数
* @param records 数据列表
* @param <T> 数据类型
* @return 分页结果
*/
public static <T> PageResult<T> of(Long pageNum, Long pageSize, Long total, List<T> records) {
PageResult<T> result = new PageResult<>();
result.setPageNum(pageNum);
result.setPageSize(pageSize);
result.setTotal(total);
result.setPages(calculatePages(total, pageSize));
result.setRecords(records == null ? Collections.emptyList() : records);
return result;
}
/**
* 从 MyBatis-Plus 分页对象创建分页结果
*
* @param page MyBatis-Plus 分页对象
* @param <T> 数据类型
* @return 分页结果
*/
public static <T> PageResult<T> of(IPage<T> page) {
return of(page.getCurrent(), page.getSize(), page.getTotal(), page.getRecords());
}
/**
* 空分页结果
*
* @param pageNum 当前页
* @param pageSize 每页条数
* @param <T> 数据类型
* @return 空分页结果
*/
public static <T> PageResult<T> empty(Long pageNum, Long pageSize) {
return of(pageNum, pageSize, 0L, Collections.emptyList());
}
/**
* 计算总页数
*
* @param total 总条数
* @param pageSize 每页条数
* @return 总页数
*/
private static Long calculatePages(Long total, Long pageSize) {
if (total == null || pageSize == null || pageSize <= 0) {
return 0L;
}
return (total + pageSize - 1) / pageSize;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
响应示例:
{
"pageNum": 1,
"pageSize": 10,
"total": 25,
"pages": 3,
"records": []
}2
3
4
5
6
7
ApiResult
ApiResult 用于统一接口返回结构。它应包含业务状态码、提示信息、数据、成功标识、时间戳等字段。统一响应结构便于前端处理、网关日志分析、异常处理和接口规范维护。
ApiResult 封装成功、失败和自定义状态码响应。
package io.github.atengk.common.result;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 统一接口响应对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class ApiResult<T> implements Serializable {
/**
* 状态码
*/
private Integer code;
/**
* 响应消息
*/
private String message;
/**
* 响应数据
*/
private T data;
/**
* 是否成功
*/
private Boolean success;
/**
* 响应时间
*/
private LocalDateTime timestamp;
/**
* 成功空响应
*
* @return 统一响应
*/
public static ApiResult<Void> success() {
return success(null);
}
/**
* 成功响应
*
* @param data 响应数据
* @param <T> 数据类型
* @return 统一响应
*/
public static <T> ApiResult<T> success(T data) {
return of(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data, true);
}
/**
* 失败响应
*
* @param message 响应消息
* @return 统一响应
*/
public static ApiResult<Void> fail(String message) {
return of(ResultCode.BUSINESS_ERROR.getCode(), message, null, false);
}
/**
* 失败响应
*
* @param resultCode 状态码枚举
* @return 统一响应
*/
public static ApiResult<Void> fail(ResultCode resultCode) {
return of(resultCode.getCode(), resultCode.getMessage(), null, false);
}
/**
* 创建响应对象
*
* @param code 状态码
* @param message 响应消息
* @param data 响应数据
* @param success 是否成功
* @param <T> 数据类型
* @return 统一响应
*/
public static <T> ApiResult<T> of(Integer code, String message, T data, Boolean success) {
ApiResult<T> result = new ApiResult<>();
result.setCode(code);
result.setMessage(message);
result.setData(data);
result.setSuccess(success);
result.setTimestamp(LocalDateTime.now());
return result;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
状态码枚举:
package io.github.atengk.common.result;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 统一响应状态码
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum ResultCode {
/**
* 操作成功
*/
SUCCESS(200, "操作成功"),
/**
* 业务处理失败
*/
BUSINESS_ERROR(5001, "业务处理失败"),
/**
* 参数校验失败
*/
PARAM_VALIDATE_ERROR(4001, "参数校验失败"),
/**
* 未登录
*/
UNAUTHORIZED(401, "未登录"),
/**
* 无权限
*/
FORBIDDEN(403, "无权限访问"),
/**
* 数据不存在
*/
DATA_NOT_FOUND(5002, "数据不存在"),
/**
* 数据已存在
*/
DATA_DUPLICATE(5003, "数据已存在"),
/**
* 数据版本冲突
*/
DATA_VERSION_CONFLICT(5006, "数据已被修改,请刷新后重试");
private final Integer code;
private final String message;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
ApiResult 建议:
| 字段 | 建议 |
|---|---|
code | 使用业务状态码 |
message | 使用用户可理解提示 |
data | 返回业务数据 |
success | 便于前端快速判断 |
timestamp | 便于排查响应时间 |
| HTTP 状态码 | 可统一 200,也可按异常类型区分 |
| 异常响应 | 统一由全局异常处理器封装 |
Tree 结构封装
树结构常用于部门、菜单、区域、分类、组织架构等层级数据。推荐定义统一的树节点接口和构建工具,避免每个模块重复写递归组装逻辑。
树节点接口:
package io.github.atengk.common.tree;
import java.util.List;
/**
* 树节点基础接口
*
* @author Ateng
* @since 2026-05-05
*/
public interface TreeNode<T extends TreeNode<T>> {
/**
* 获取节点 ID
*
* @return 节点 ID
*/
Long getId();
/**
* 获取父节点 ID
*
* @return 父节点 ID
*/
Long getParentId();
/**
* 设置子节点
*
* @param children 子节点列表
*/
void setChildren(List<T> children);
/**
* 获取排序值
*
* @return 排序值
*/
Integer getSortOrder();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
树构建工具:
package io.github.atengk.common.tree;
import cn.hutool.core.collection.CollUtil;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 树结构构建工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class TreeBuilder {
private TreeBuilder() {
}
/**
* 构建树结构
*
* @param records 节点列表
* @param rootParentId 根节点父 ID
* @param <T> 节点类型
* @return 树结构
*/
public static <T extends TreeNode<T>> List<T> build(List<T> records, Long rootParentId) {
if (CollUtil.isEmpty(records)) {
return List.of();
}
Map<Long, List<T>> parentMap = records.stream()
.collect(Collectors.groupingBy(TreeNode::getParentId));
records.forEach(node -> {
List<T> children = parentMap.getOrDefault(node.getId(), List.of())
.stream()
.sorted(Comparator.comparing(
TreeNode::getSortOrder,
Comparator.nullsLast(Integer::compareTo)
))
.toList();
node.setChildren(children);
});
return parentMap.getOrDefault(rootParentId, List.of())
.stream()
.sorted(Comparator.comparing(
TreeNode::getSortOrder,
Comparator.nullsLast(Integer::compareTo)
))
.toList();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
部门树 VO:
package io.github.atengk.module.system.dept.vo;
import io.github.atengk.common.tree.TreeNode;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 部门树返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class DeptTreeVO implements TreeNode<DeptTreeVO> {
/**
* 部门 ID
*/
private Long id;
/**
* 父部门 ID
*/
private Long parentId;
/**
* 部门名称
*/
private String deptName;
/**
* 排序值
*/
private Integer sortOrder;
/**
* 子部门
*/
private List<DeptTreeVO> children;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
使用示例:
public List<DeptTreeVO> listDeptTree() {
List<DeptEntity> entities = deptService.lambdaQuery()
.eq(DeptEntity::getStatus, 1)
.orderByAsc(DeptEntity::getSortOrder)
.list();
if (CollUtil.isEmpty(entities)) {
return List.of();
}
List<DeptTreeVO> records = deptConvert.toTreeVOList(entities);
return TreeBuilder.build(records, 0L);
}2
3
4
5
6
7
8
9
10
11
12
13
树结构建议:
| 场景 | 建议 |
|---|---|
| 部门树 | 使用统一 TreeBuilder |
| 菜单树 | 使用统一 TreeBuilder |
| 分类树 | 使用统一 TreeBuilder |
| 数据量较小 | 一次性查出内存组装 |
| 数据量较大 | 按需懒加载 |
| 树层级过深 | 限制最大层级 |
| 排序字段 | 统一使用 sortOrder |
字典回显封装
字典回显用于把数据库中的编码转换为展示名称,例如 status = 1 回显为“启用”。稳定状态可以用枚举,运营可配置项应使用字典表。通用封装应支持按字典类型批量加载,避免循环查询字典。
字典项 VO:
package io.github.atengk.common.dict;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 字典项返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class DictItemVO implements Serializable {
/**
* 字典类型
*/
private String dictType;
/**
* 字典值
*/
private String dictValue;
/**
* 字典标签
*/
private String dictLabel;
/**
* 标签样式
*/
private String tagType;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
字典服务接口:
package io.github.atengk.common.dict;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* 字典服务
*
* @author Ateng
* @since 2026-05-05
*/
public interface DictService {
/**
* 根据字典类型查询字典项
*
* @param dictType 字典类型
* @return 字典项列表
*/
List<DictItemVO> listByDictType(String dictType);
/**
* 批量查询字典项 Map
*
* @param dictTypes 字典类型集合
* @return 字典类型到字典项列表映射
*/
Map<String, List<DictItemVO>> mapByDictTypes(Collection<String> dictTypes);
/**
* 获取字典标签
*
* @param dictType 字典类型
* @param dictValue 字典值
* @return 字典标签
*/
String getDictLabel(String dictType, String dictValue);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
字典服务实现示例:
package io.github.atengk.common.dict.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.dict.DictItemVO;
import io.github.atengk.common.dict.DictService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 字典服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Service
@RequiredArgsConstructor
public class DictServiceImpl implements DictService {
private final SysDictDataMapper sysDictDataMapper;
/**
* 根据字典类型查询字典项
*
* @param dictType 字典类型
* @return 字典项列表
*/
@Override
public List<DictItemVO> listByDictType(String dictType) {
if (StrUtil.isBlank(dictType)) {
return List.of();
}
return sysDictDataMapper.selectDictItemsByType(dictType);
}
/**
* 批量查询字典项 Map
*
* @param dictTypes 字典类型集合
* @return 字典类型到字典项列表映射
*/
@Override
public Map<String, List<DictItemVO>> mapByDictTypes(Collection<String> dictTypes) {
if (CollUtil.isEmpty(dictTypes)) {
return Map.of();
}
List<DictItemVO> records = sysDictDataMapper.selectDictItemsByTypes(dictTypes);
if (CollUtil.isEmpty(records)) {
return Map.of();
}
return records.stream().collect(Collectors.groupingBy(DictItemVO::getDictType));
}
/**
* 获取字典标签
*
* @param dictType 字典类型
* @param dictValue 字典值
* @return 字典标签
*/
@Override
public String getDictLabel(String dictType, String dictValue) {
if (StrUtil.hasBlank(dictType, dictValue)) {
return "";
}
return listByDictType(dictType).stream()
.filter(item -> StrUtil.equals(item.getDictValue(), dictValue))
.map(DictItemVO::getDictLabel)
.findFirst()
.orElse(dictValue);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
字典回显示例:
public List<UserPageVO> fillUserDictLabel(List<UserPageVO> records) {
if (CollUtil.isEmpty(records)) {
return List.of();
}
List<DictItemVO> statusDictItems = dictService.listByDictType("user_status");
Map<String, String> statusLabelMap = statusDictItems.stream()
.collect(Collectors.toMap(DictItemVO::getDictValue, DictItemVO::getDictLabel, (a, b) -> a));
records.forEach(item -> item.setStatusName(statusLabelMap.getOrDefault(
String.valueOf(item.getStatus()),
String.valueOf(item.getStatus())
)));
return records;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
字典回显建议:
| 场景 | 建议 |
|---|---|
| 稳定状态 | 枚举优先 |
| 可配置值 | 字典表 |
| 列表回显 | 批量加载字典 |
| 高频字典 | 加缓存 |
| 多语言 | 字典标签按语言处理 |
| 导出 | 使用字典标签 |
| 查询条件 | 入参使用字典值,不使用标签 |
当前用户上下文
当前用户上下文用于在 Service、自动填充、数据权限、多租户、操作日志等场景中获取当前登录用户信息。常见实现方式是认证成功后将用户信息放入 ThreadLocal,请求结束后清理。
当前登录用户对象:
package io.github.atengk.framework.security;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
import java.util.Set;
/**
* 当前登录用户
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class LoginUser implements Serializable {
/**
* 用户 ID
*/
private Long userId;
/**
* 租户 ID
*/
private Long tenantId;
/**
* 部门 ID
*/
private Long deptId;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 是否超级管理员
*/
private Boolean superAdmin = false;
/**
* 权限标识集合
*/
private Set<String> permissions = Collections.emptySet();
/**
* 角色编码集合
*/
private Set<String> roleCodes = Collections.emptySet();
/**
* 可访问部门 ID 列表
*/
private List<Long> dataDeptIds = Collections.emptyList();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
当前用户上下文:
package io.github.atengk.framework.security;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.ObjectUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 当前用户上下文
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class CurrentUserContext {
private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>();
/**
* 设置当前登录用户
*
* @param loginUser 登录用户
*/
public void setLoginUser(LoginUser loginUser) {
USER_HOLDER.set(loginUser);
}
/**
* 获取当前登录用户
*
* @return 登录用户
*/
public LoginUser getLoginUser() {
return USER_HOLDER.get();
}
/**
* 获取当前登录用户,未登录时返回默认系统用户
*
* @return 登录用户
*/
public LoginUser getLoginUserOrDefault() {
LoginUser loginUser = getLoginUser();
if (ObjectUtil.isNotNull(loginUser)) {
return loginUser;
}
LoginUser defaultUser = new LoginUser();
defaultUser.setUserId(0L);
defaultUser.setTenantId(0L);
defaultUser.setDeptId(0L);
defaultUser.setUsername("system");
defaultUser.setNickname("系统");
defaultUser.setSuperAdmin(false);
return defaultUser;
}
/**
* 获取当前用户 ID
*
* @return 用户 ID
*/
public Long getUserIdOrDefault() {
return getLoginUserOrDefault().getUserId();
}
/**
* 获取当前租户 ID
*
* @return 租户 ID
*/
public Long getTenantIdOrDefault() {
return getLoginUserOrDefault().getTenantId();
}
/**
* 获取当前部门 ID
*
* @return 部门 ID
*/
public Long getDeptIdOrDefault() {
return getLoginUserOrDefault().getDeptId();
}
/**
* 判断当前用户是否超级管理员
*
* @return 是否超级管理员
*/
public boolean isSuperAdmin() {
return BooleanUtil.isTrue(getLoginUserOrDefault().getSuperAdmin());
}
/**
* 清理当前登录用户
*/
public void clear() {
USER_HOLDER.remove();
log.trace("当前用户上下文已清理");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
Web 拦截器中设置和清理上下文:
package io.github.atengk.framework.web;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 用户上下文拦截器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
@RequiredArgsConstructor
public class UserContextInterceptor implements HandlerInterceptor {
private final CurrentUserContext currentUserContext;
/**
* 请求进入时设置当前用户上下文
*
* @param request 请求对象
* @param response 响应对象
* @param handler 处理器
* @return 是否继续处理
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
LoginUser loginUser = new LoginUser();
loginUser.setUserId(Convert.toLong(request.getHeader("X-User-Id"), 0L));
loginUser.setTenantId(Convert.toLong(request.getHeader("X-Tenant-Id"), 0L));
loginUser.setDeptId(Convert.toLong(request.getHeader("X-Dept-Id"), 0L));
loginUser.setUsername(StrUtil.blankToDefault(request.getHeader("X-Username"), "system"));
currentUserContext.setLoginUser(loginUser);
return true;
}
/**
* 请求完成后清理当前用户上下文
*
* @param request 请求对象
* @param response 响应对象
* @param handler 处理器
* @param ex 异常对象
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
currentUserContext.clear();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
当前用户上下文建议:
| 场景 | 建议 |
|---|---|
| Web 请求 | 拦截器中设置,请求结束清理 |
| 自动填充 | 从上下文获取创建人、更新人 |
| 数据权限 | 从上下文获取用户和部门范围 |
| 多租户 | 从上下文获取租户 ID |
| 异步任务 | 显式传递上下文 |
| 定时任务 | 使用系统用户或显式设置 |
| ThreadLocal | 必须清理,防止线程复用污染 |
操作日志封装
操作日志用于记录用户对关键业务数据的新增、修改、删除、导入、导出、审批、授权等操作。操作日志应记录操作人、租户、请求路径、请求方法、业务类型、业务 ID、耗时、结果、异常信息等。
操作日志注解:
package io.github.atengk.framework.log;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* 操作日志注解
*
* @author Ateng
* @since 2026-05-05
*/
@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface OperationLog {
/**
* 业务模块
*
* @return 业务模块
*/
String module();
/**
* 操作类型
*
* @return 操作类型
*/
String type();
/**
* 操作说明
*
* @return 操作说明
*/
String description() default "";
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
操作日志实体:
package io.github.atengk.framework.log.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import io.github.atengk.common.entity.TenantBaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 操作日志实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_operation_log")
public class OperationLogEntity extends TenantBaseEntity {
/**
* 操作模块
*/
private String module;
/**
* 操作类型
*/
private String operationType;
/**
* 操作说明
*/
private String description;
/**
* 请求路径
*/
private String requestUri;
/**
* 请求方法
*/
private String requestMethod;
/**
* 操作人 ID
*/
private Long operatorId;
/**
* 操作人名称
*/
private String operatorName;
/**
* 请求 IP
*/
private String requestIp;
/**
* 请求参数
*/
private String requestParams;
/**
* 是否成功
*/
private Integer successFlag;
/**
* 错误信息
*/
private String errorMessage;
/**
* 执行耗时,单位毫秒
*/
private Long costMillis;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
操作日志切面用于拦截带 @OperationLog 的方法,自动记录日志。
package io.github.atengk.framework.log;
import cn.hutool.core.date.StopWatch;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.json.JSONUtil;
import io.github.atengk.framework.log.entity.OperationLogEntity;
import io.github.atengk.framework.log.service.OperationLogService;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 操作日志切面
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class OperationLogAspect {
private final OperationLogService operationLogService;
private final CurrentUserContext currentUserContext;
/**
* 记录操作日志
*
* @param joinPoint 切点
* @param operationLog 操作日志注解
* @return 方法返回值
* @throws Throwable 业务异常
*/
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
boolean success = true;
String errorMessage = null;
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
success = false;
errorMessage = throwable.getMessage();
throw throwable;
} finally {
stopWatch.stop();
saveOperationLog(joinPoint, operationLog, success, errorMessage, stopWatch.getTotalTimeMillis());
}
}
/**
* 保存操作日志
*
* @param joinPoint 切点
* @param operationLog 操作日志注解
* @param success 是否成功
* @param errorMessage 错误信息
* @param costMillis 执行耗时
*/
private void saveOperationLog(ProceedingJoinPoint joinPoint,
OperationLog operationLog,
boolean success,
String errorMessage,
long costMillis) {
try {
HttpServletRequest request = getRequest();
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
OperationLogEntity entity = new OperationLogEntity();
entity.setTenantId(loginUser.getTenantId());
entity.setModule(operationLog.module());
entity.setOperationType(operationLog.type());
entity.setDescription(operationLog.description());
entity.setOperatorId(loginUser.getUserId());
entity.setOperatorName(loginUser.getUsername());
entity.setSuccessFlag(success ? 1 : 0);
entity.setErrorMessage(StrUtil.sub(errorMessage, 0, 500));
entity.setCostMillis(costMillis);
if (request != null) {
entity.setRequestUri(request.getRequestURI());
entity.setRequestMethod(request.getMethod());
entity.setRequestIp(JakartaServletUtil.getClientIP(request));
entity.setRequestParams(StrUtil.sub(JSONUtil.toJsonStr(joinPoint.getArgs()), 0, 2000));
}
operationLogService.saveOperationLog(entity);
} catch (Exception exception) {
log.error("保存操作日志失败", exception);
}
}
/**
* 获取当前请求对象
*
* @return 当前请求对象
*/
private HttpServletRequest getRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes == null ? null : attributes.getRequest();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
操作日志服务建议使用独立事务,避免主业务事务回滚导致操作日志丢失。
package io.github.atengk.framework.log.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.framework.log.entity.OperationLogEntity;
import io.github.atengk.framework.log.mapper.OperationLogMapper;
import io.github.atengk.framework.log.service.OperationLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* 操作日志服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class OperationLogServiceImpl
extends ServiceImpl<OperationLogMapper, OperationLogEntity>
implements OperationLogService {
/**
* 保存操作日志
*
* @param entity 操作日志实体
*/
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void saveOperationLog(OperationLogEntity entity) {
this.save(entity);
log.debug("保存操作日志成功,模块:{},类型:{},操作人:{}",
entity.getModule(), entity.getOperationType(), entity.getOperatorName());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Controller 使用示例:
@OperationLog(module = "用户管理", type = "新增", description = "新增用户")
@PostMapping
public ApiResult<Long> addUser(@RequestBody @Valid UserAddDTO dto) {
return success(userService.addUser(dto));
}2
3
4
5
操作日志建议:
| 场景 | 是否记录 |
|---|---|
| 新增 | 建议 |
| 修改 | 建议 |
| 删除 | 必须 |
| 批量删除 | 必须 |
| 导入 | 必须 |
| 导出 | 必须 |
| 审批 | 必须 |
| 授权 | 必须 |
| 查询列表 | 通常不记录,除非敏感数据 |
| 查看详情 | 敏感数据详情建议记录 |
操作日志中不要记录密码、Token、密钥、完整身份证号、完整银行卡号等敏感数据。请求参数记录应截断长度,并配合脱敏处理。
常见业务模型
常见业务模型是后台管理系统和业务中台项目的基础模块集合。Spring Boot 3 + MyBatis-Plus 项目中,这些模型通常遵循统一的分层结构:Entity 承载表字段,DTO 接收入参,Query 承接查询条件,VO 返回接口结果,Service 处理业务规则,Mapper 处理数据库访问。
这些模型不要都做成“通用 CRUD”。用户、角色、菜单、部门、订单、库存、审批等模块都有不同的业务约束,基础增删改查之外必须补充唯一性校验、状态流转、数据权限、操作日志、事务边界和并发控制。
用户管理
用户管理用于维护系统登录账号、用户资料、用户状态、所属部门、岗位、角色关系等信息。用户表是权限系统和数据权限的核心表,通常会被登录认证、操作日志、数据权限、自动填充等多个模块依赖。
核心表建议:
CREATE TABLE sys_user (
id BIGINT NOT NULL COMMENT '用户ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
dept_id BIGINT NOT NULL DEFAULT 0 COMMENT '部门ID',
username VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
password VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码',
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
phone VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号',
email VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱',
avatar VARCHAR(500) NOT NULL DEFAULT '' COMMENT '头像地址',
gender TINYINT NOT NULL DEFAULT 0 COMMENT '性别:0未知,1男,2女',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
last_login_time DATETIME NULL COMMENT '最后登录时间',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_username (tenant_id, username),
UNIQUE KEY uk_tenant_phone (tenant_id, phone),
KEY idx_tenant_dept (tenant_id, dept_id),
KEY idx_tenant_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
用户管理开发要点:
| 场景 | 建议 |
|---|---|
| 新增用户 | 校验用户名、手机号租户内唯一 |
| 修改用户 | 不允许普通接口修改密码、租户、逻辑删除字段 |
| 删除用户 | 系统内置管理员不允许删除 |
| 禁用用户 | 禁用后应使登录态失效 |
| 密码存储 | 只存加密哈希,不存明文 |
| 用户角色 | 使用 sys_user_role 关系表 |
| 数据权限 | 通常基于用户所属部门和角色数据范围 |
| 返回接口 | 不返回密码、盐、Token、密钥字段 |
用户实体示例:
package io.github.atengk.module.system.user.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import io.github.atengk.common.entity.DeptBaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 系统用户实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_user")
public class UserEntity extends DeptBaseEntity {
/**
* 用户名
*/
private String username;
/**
* 密码哈希
*/
private String password;
/**
* 昵称
*/
private String nickname;
/**
* 手机号
*/
private String phone;
/**
* 邮箱
*/
private String email;
/**
* 头像地址
*/
private String avatar;
/**
* 性别:0未知,1男,2女
*/
private Integer gender;
/**
* 状态:0禁用,1启用
*/
private Integer status;
/**
* 最后登录时间
*/
private LocalDateTime lastLoginTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
用户新增 Service 示例,演示租户内唯一性校验和写操作日志。
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.common.result.ResultCode;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.module.system.user.convert.UserConvert;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import io.github.atengk.module.system.user.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 用户服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService {
private final UserConvert userConvert;
private final CurrentUserContext currentUserContext;
/**
* 新增用户
*
* @param dto 用户新增参数
* @return 用户ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long addUser(UserAddDTO dto) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
checkUsernameUnique(tenantId, dto.getUsername(), null);
checkPhoneUnique(tenantId, dto.getPhone(), null);
UserEntity entity = userConvert.toEntity(dto);
entity.setTenantId(tenantId);
boolean saved = this.save(entity);
if (!saved) {
throw new BusinessException("新增用户失败");
}
log.info("新增用户成功,租户ID:{},用户ID:{},用户名:{}", tenantId, entity.getId(), entity.getUsername());
return entity.getId();
}
/**
* 校验用户名租户内唯一
*
* @param tenantId 租户ID
* @param username 用户名
* @param excludeId 排除的用户ID
*/
private void checkUsernameUnique(Long tenantId, String username, Long excludeId) {
if (StrUtil.isBlank(username)) {
throw new BusinessException("用户名不能为空");
}
long count = this.lambdaQuery()
.eq(UserEntity::getTenantId, tenantId)
.eq(UserEntity::getUsername, username)
.ne(excludeId != null, UserEntity::getId, excludeId)
.count();
if (count > 0) {
throw new BusinessException(ResultCode.DATA_DUPLICATE, "用户名已存在");
}
}
/**
* 校验手机号租户内唯一
*
* @param tenantId 租户ID
* @param phone 手机号
* @param excludeId 排除的用户ID
*/
private void checkPhoneUnique(Long tenantId, String phone, Long excludeId) {
if (StrUtil.isBlank(phone)) {
return;
}
long count = this.lambdaQuery()
.eq(UserEntity::getTenantId, tenantId)
.eq(UserEntity::getPhone, phone)
.ne(excludeId != null, UserEntity::getId, excludeId)
.count();
if (count > 0) {
throw new BusinessException(ResultCode.DATA_DUPLICATE, "手机号已存在");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
角色管理
角色管理用于维护用户权限集合和数据范围。角色通常与菜单、按钮权限、部门数据范围绑定。一个用户可以拥有多个角色,一个角色可以分配给多个用户。
核心表建议:
CREATE TABLE sys_role (
id BIGINT NOT NULL COMMENT '角色ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
role_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '角色编码',
role_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '角色名称',
data_scope TINYINT NOT NULL DEFAULT 4 COMMENT '数据范围:1全部,2本部门,3本部门及下级,4本人,5自定义',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_role_code (tenant_id, role_code),
KEY idx_tenant_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统角色表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
关系表建议:
CREATE TABLE sys_user_role (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
role_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_user_role (tenant_id, user_id, role_id),
KEY idx_role_id (role_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关系表';
CREATE TABLE sys_role_menu (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
role_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色ID',
menu_id BIGINT NOT NULL DEFAULT 0 COMMENT '菜单ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_role_menu (tenant_id, role_id, menu_id),
KEY idx_menu_id (menu_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关系表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
角色管理开发要点:
| 场景 | 建议 |
|---|---|
| 新增角色 | 校验角色编码租户内唯一 |
| 修改角色 | 系统内置角色限制修改 |
| 删除角色 | 已分配用户的角色不允许直接删除 |
| 分配菜单 | 先删后增,放同一事务 |
| 分配数据范围 | 自定义数据范围写入 sys_role_dept |
| 禁用角色 | 禁用后应刷新用户权限缓存 |
| 权限缓存 | 用户登录后可缓存角色和权限标识 |
菜单管理
菜单管理用于维护系统菜单、按钮、接口权限标识和路由元数据。菜单通常是树结构,支持目录、菜单、按钮三种类型。
核心表建议:
CREATE TABLE sys_menu (
id BIGINT NOT NULL COMMENT '菜单ID',
parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父菜单ID',
menu_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '菜单名称',
menu_type TINYINT NOT NULL DEFAULT 1 COMMENT '菜单类型:1目录,2菜单,3按钮',
path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '路由路径',
component VARCHAR(255) NOT NULL DEFAULT '' COMMENT '组件路径',
permission VARCHAR(128) NOT NULL DEFAULT '' COMMENT '权限标识',
icon VARCHAR(128) NOT NULL DEFAULT '' COMMENT '图标',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
visible TINYINT NOT NULL DEFAULT 1 COMMENT '是否显示:0隐藏,1显示',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_permission (permission),
KEY idx_parent_id (parent_id),
KEY idx_sort_order (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
菜单管理开发要点:
| 场景 | 建议 |
|---|---|
| 菜单树 | 一次性查出后内存构建树 |
| 权限标识 | 按模块统一命名,例如 system:user:add |
| 按钮权限 | 作为菜单类型 3 管理 |
| 删除菜单 | 有子菜单时不允许删除 |
| 菜单缓存 | 用户登录时缓存可访问菜单和权限 |
| 平台菜单 | 多租户场景下通常作为平台公共表 |
| 路由字段 | 前端菜单路由需要稳定,不建议频繁变更 |
菜单类型枚举示例:
package io.github.atengk.module.system.menu.enums;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 菜单类型枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum MenuTypeEnum {
/**
* 目录
*/
DIRECTORY(1, "目录"),
/**
* 菜单
*/
MENU(2, "菜单"),
/**
* 按钮
*/
BUTTON(3, "按钮");
/**
* 类型编码
*/
private final Integer code;
/**
* 类型名称
*/
private final String name;
/**
* 根据编码获取枚举
*
* @param code 类型编码
* @return 菜单类型
*/
public static MenuTypeEnum ofCode(Integer code) {
return Arrays.stream(values())
.filter(item -> ObjectUtil.equals(item.getCode(), code))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("菜单类型不存在:" + code));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
部门管理
部门管理用于维护组织架构,常用于用户归属、数据权限、审批流和统计维度。部门通常是树结构,建议设计 parent_id 和 ancestors 字段,方便查询上级链路和下级部门。
核心表建议:
CREATE TABLE sys_dept (
id BIGINT NOT NULL COMMENT '部门ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父部门ID',
ancestors VARCHAR(500) NOT NULL DEFAULT '' COMMENT '祖级列表',
dept_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '部门名称',
dept_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '部门编码',
leader_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '负责人用户ID',
phone VARCHAR(20) NOT NULL DEFAULT '' COMMENT '联系电话',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_dept_code (tenant_id, dept_code),
KEY idx_tenant_parent (tenant_id, parent_id),
KEY idx_tenant_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统部门表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
部门管理开发要点:
| 场景 | 建议 |
|---|---|
| 新增部门 | 校验同租户下部门编码唯一 |
| 修改部门 | 不允许把父部门改成自己或自己的子部门 |
| 删除部门 | 有子部门、有用户时不允许删除 |
| 部门树 | 使用统一 TreeBuilder |
| 数据权限 | 本部门及下级部门依赖部门树或 ancestors |
| 禁用部门 | 需要校验是否存在启用子部门或用户 |
| 部门负责人 | 只存用户 ID,回显时批量查询用户名称 |
岗位管理
岗位管理用于维护用户在组织中的岗位信息,例如开发工程师、产品经理、销售主管等。岗位通常与用户多对多或一对多关联,具体取决于业务是否允许一个用户多个岗位。
核心表建议:
CREATE TABLE sys_post (
id BIGINT NOT NULL COMMENT '岗位ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
post_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '岗位编码',
post_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '岗位名称',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_post_code (tenant_id, post_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统岗位表';2
3
4
5
6
7
8
9
10
11
12
13
14
用户岗位关系表:
CREATE TABLE sys_user_post (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
post_id BIGINT NOT NULL DEFAULT 0 COMMENT '岗位ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_user_post (tenant_id, user_id, post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户岗位关系表';2
3
4
5
6
7
8
9
10
岗位管理开发要点:
| 场景 | 建议 |
|---|---|
| 新增岗位 | 校验岗位编码唯一 |
| 删除岗位 | 已绑定用户时不允许删除 |
| 分配岗位 | 与用户保存放同一事务 |
| 岗位回显 | 用户列表中批量查询岗位名称 |
| 岗位权限 | 通常不直接作为权限主体,角色才是权限主体 |
| 岗位排序 | 用于下拉框展示 |
字典管理
字典管理用于维护可配置的枚举类数据,例如性别、状态、业务类型、标签样式等。稳定且强业务语义的状态建议使用 Java 枚举;运营可维护的值建议使用字典表。
字典类型表:
CREATE TABLE sys_dict_type (
id BIGINT NOT NULL COMMENT '字典类型ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
dict_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '字典名称',
dict_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '字典类型',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_dict_type (tenant_id, dict_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字典类型表';2
3
4
5
6
7
8
9
10
11
12
13
字典数据表:
CREATE TABLE sys_dict_data (
id BIGINT NOT NULL COMMENT '字典数据ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
dict_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '字典类型',
dict_value VARCHAR(64) NOT NULL DEFAULT '' COMMENT '字典值',
dict_label VARCHAR(128) NOT NULL DEFAULT '' COMMENT '字典标签',
tag_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '标签样式',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_type_value (tenant_id, dict_type, dict_value),
KEY idx_tenant_type (tenant_id, dict_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字典数据表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
字典管理开发要点:
| 场景 | 建议 |
|---|---|
| 新增字典类型 | 校验 dict_type 唯一 |
| 删除字典类型 | 有字典数据时不允许删除 |
| 字典缓存 | 按 tenantId + dictType 缓存 |
| 字典回显 | 列表页批量加载字典 |
| 字典刷新 | 修改后清理缓存 |
| 多租户 | 平台字典和租户字典要区分 |
| 国际化 | 不建议直接把中文标签写死到业务逻辑中 |
参数配置
参数配置用于维护系统运行参数,例如文件大小限制、默认密码策略、登录失败次数、系统开关等。参数配置通常是键值结构,读取频率高,适合缓存。
核心表建议:
CREATE TABLE sys_config (
id BIGINT NOT NULL COMMENT '参数ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
config_key VARCHAR(128) NOT NULL DEFAULT '' COMMENT '参数键',
config_value VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '参数值',
config_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '参数名称',
config_type TINYINT NOT NULL DEFAULT 1 COMMENT '参数类型:1系统内置,2业务配置',
encrypted TINYINT NOT NULL DEFAULT 0 COMMENT '是否加密:0否,1是',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_config_key (tenant_id, config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统参数配置表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
参数配置开发要点:
| 场景 | 建议 |
|---|---|
| 参数读取 | 优先读取缓存 |
| 参数修改 | 修改后清理缓存 |
| 内置参数 | 不允许普通管理员删除 |
| 敏感参数 | 加密存储,接口返回脱敏 |
| 参数类型 | 明确字符串、数字、布尔、JSON |
| 默认值 | 读取不到时使用后端默认值 |
| 多租户 | 租户配置可覆盖平台默认配置 |
配置服务示例:
package io.github.atengk.module.system.config.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.module.system.config.entity.ConfigEntity;
import io.github.atengk.module.system.config.mapper.ConfigMapper;
import io.github.atengk.module.system.config.service.ConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 参数配置服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class ConfigServiceImpl extends ServiceImpl<ConfigMapper, ConfigEntity> implements ConfigService {
/**
* 根据参数键获取参数值
*
* @param tenantId 租户ID
* @param configKey 参数键
* @param defaultValue 默认值
* @return 参数值
*/
@Override
public String getConfigValue(Long tenantId, String configKey, String defaultValue) {
if (StrUtil.isBlank(configKey)) {
throw new BusinessException("参数键不能为空");
}
ConfigEntity entity = this.lambdaQuery()
.eq(ConfigEntity::getTenantId, tenantId)
.eq(ConfigEntity::getConfigKey, configKey)
.last("LIMIT 1")
.one();
if (entity == null || StrUtil.isBlank(entity.getConfigValue())) {
return defaultValue;
}
return entity.getConfigValue();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
文件管理
文件管理用于维护文件上传、下载、预览、删除、归档等信息。数据库只应保存文件元数据,不建议把大文件内容存入数据库。文件本体通常存储到 MinIO、OSS、S3、本地磁盘或分布式文件系统。
核心表建议:
CREATE TABLE sys_file (
id BIGINT NOT NULL COMMENT '文件ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
file_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '原始文件名',
file_ext VARCHAR(32) NOT NULL DEFAULT '' COMMENT '文件扩展名',
file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小,单位字节',
content_type VARCHAR(128) NOT NULL DEFAULT '' COMMENT '文件类型',
storage_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '存储类型:local、minio、oss',
bucket_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '存储桶',
object_name VARCHAR(500) NOT NULL DEFAULT '' COMMENT '对象名称',
access_url VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '访问地址',
file_hash VARCHAR(128) NOT NULL DEFAULT '' COMMENT '文件哈希',
business_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '业务类型',
business_id BIGINT NOT NULL DEFAULT 0 COMMENT '业务ID',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '上传人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
KEY idx_tenant_biz (tenant_id, business_type, business_id),
KEY idx_file_hash (file_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统文件表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
文件管理开发要点:
| 场景 | 建议 |
|---|---|
| 上传文件 | 校验大小、扩展名、MIME 类型 |
| 文件命名 | 使用雪花 ID 或 UUID 作为对象名 |
| 文件秒传 | 可基于 hash 判断 |
| 文件删除 | 先逻辑删除元数据,再异步删除物理文件 |
| 文件下载 | 校验权限后返回临时 URL 或流 |
| 文件预览 | 图片、PDF 可单独处理 |
| 多租户 | 文件路径中可包含租户 ID |
| 安全 | 禁止直接使用前端文件名作为存储路径 |
操作日志
操作日志用于记录用户对系统中关键数据的操作行为,例如新增、修改、删除、导入、导出、审批、授权等。操作日志主要用于审计、追责和问题排查。
核心表建议:
CREATE TABLE sys_operation_log (
id BIGINT NOT NULL COMMENT '日志ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
module VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作模块',
operation_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作类型',
description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '操作说明',
request_uri VARCHAR(500) NOT NULL DEFAULT '' COMMENT '请求地址',
request_method VARCHAR(20) NOT NULL DEFAULT '' COMMENT '请求方法',
operator_id BIGINT NOT NULL DEFAULT 0 COMMENT '操作人ID',
operator_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称',
request_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求IP',
request_params TEXT NULL COMMENT '请求参数',
success_flag TINYINT NOT NULL DEFAULT 1 COMMENT '是否成功:0失败,1成功',
error_message VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '错误信息',
cost_millis BIGINT NOT NULL DEFAULT 0 COMMENT '耗时毫秒',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (id),
KEY idx_tenant_time (tenant_id, create_time),
KEY idx_operator_time (operator_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
操作日志开发要点:
| 场景 | 建议 |
|---|---|
| 记录方式 | 注解 + AOP |
| 事务策略 | 使用独立事务或异步写入 |
| 敏感参数 | 密码、Token、密钥必须脱敏或排除 |
| 日志大小 | 请求参数截断长度 |
| 查询分页 | 必须按时间范围查询 |
| 清理策略 | 按月归档或分表 |
| 导出日志 | 需要管理员权限 |
登录日志
登录日志用于记录用户登录、登出、登录失败、验证码失败、账号锁定等认证相关行为。登录日志有助于安全审计和异常登录分析。
核心表建议:
CREATE TABLE sys_login_log (
id BIGINT NOT NULL COMMENT '登录日志ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
username VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
login_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '登录类型:password、sms、oauth',
login_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录IP',
user_agent VARCHAR(1000) NOT NULL DEFAULT '' COMMENT 'User-Agent',
login_location VARCHAR(255) NOT NULL DEFAULT '' COMMENT '登录地点',
success_flag TINYINT NOT NULL DEFAULT 0 COMMENT '是否成功:0失败,1成功',
failure_reason VARCHAR(500) NOT NULL DEFAULT '' COMMENT '失败原因',
login_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '登录时间',
PRIMARY KEY (id),
KEY idx_tenant_username_time (tenant_id, username, login_time),
KEY idx_user_time (user_id, login_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录日志表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
登录日志开发要点:
| 场景 | 建议 |
|---|---|
| 登录成功 | 记录用户 ID、租户、IP、设备信息 |
| 登录失败 | 记录失败原因,但不要暴露过多细节给前端 |
| 密码错误 | 可配合失败次数锁定 |
| 异地登录 | 可触发安全提醒 |
| 登出 | 可记录登出时间或作为操作日志 |
| 日志清理 | 按时间归档 |
| 安全分析 | 按 IP、账号、失败次数统计 |
通知公告
通知公告用于发布系统公告、业务通知、站内信等内容。公告通常有发布状态、发布时间、可见范围、阅读记录等概念。
公告表:
CREATE TABLE sys_notice (
id BIGINT NOT NULL COMMENT '公告ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
title VARCHAR(200) NOT NULL DEFAULT '' COMMENT '公告标题',
content TEXT NULL COMMENT '公告内容',
notice_type TINYINT NOT NULL DEFAULT 1 COMMENT '公告类型:1通知,2公告',
publish_status TINYINT NOT NULL DEFAULT 0 COMMENT '发布状态:0草稿,1已发布,2已下线',
publish_time DATETIME NULL COMMENT '发布时间',
expire_time DATETIME NULL COMMENT '过期时间',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
KEY idx_tenant_status_time (tenant_id, publish_status, publish_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知公告表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
阅读记录表:
CREATE TABLE sys_notice_read (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
notice_id BIGINT NOT NULL DEFAULT 0 COMMENT '公告ID',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
read_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '阅读时间',
PRIMARY KEY (id),
UNIQUE KEY uk_notice_user (tenant_id, notice_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公告阅读记录表';2
3
4
5
6
7
8
9
通知公告开发要点:
| 场景 | 建议 |
|---|---|
| 发布公告 | 从草稿变为已发布,记录发布时间 |
| 下线公告 | 不物理删除历史内容 |
| 阅读状态 | 使用阅读记录表 |
| 可见范围 | 可扩展部门、角色、用户范围表 |
| 首页未读 | 按用户 ID 查询未读数量 |
| 内容字段 | 列表不返回大字段 content |
| 定时发布 | 定时任务扫描待发布数据 |
订单管理
订单管理是典型的业务主表 + 明细表模型。订单通常涉及状态流转、金额计算、库存扣减、支付流水、幂等处理和并发控制。
订单表:
CREATE TABLE biz_order (
id BIGINT NOT NULL COMMENT '订单ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
order_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '订单号',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID',
order_status TINYINT NOT NULL DEFAULT 10 COMMENT '订单状态:10待支付,20已支付,30已发货,40已完成,90已取消',
total_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '订单总金额',
pay_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '支付金额',
pay_time DATETIME NULL COMMENT '支付时间',
cancel_time DATETIME NULL COMMENT '取消时间',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_order_no (tenant_id, order_no),
KEY idx_tenant_user_time (tenant_id, user_id, create_time),
KEY idx_tenant_status_time (tenant_id, order_status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
订单明细表:
CREATE TABLE biz_order_item (
id BIGINT NOT NULL COMMENT '订单明细ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
order_id BIGINT NOT NULL DEFAULT 0 COMMENT '订单ID',
product_id BIGINT NOT NULL DEFAULT 0 COMMENT '商品ID',
product_name VARCHAR(200) NOT NULL DEFAULT '' COMMENT '商品名称快照',
quantity INT NOT NULL DEFAULT 0 COMMENT '购买数量',
price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '商品单价',
total_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '明细金额',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
KEY idx_tenant_order (tenant_id, order_id),
KEY idx_product_id (product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
订单管理开发要点:
| 场景 | 建议 |
|---|---|
| 创建订单 | 后端重新计算金额,不信任前端金额 |
| 订单号 | 单独生成业务单号,不直接使用主键 |
| 状态流转 | 使用枚举和状态机校验 |
| 支付回调 | 必须幂等 |
| 取消订单 | 只有待支付订单允许取消 |
| 订单明细 | 保存商品名称、价格快照 |
| 并发修改 | 使用乐观锁或条件更新 |
| 查询列表 | 不直接查询大字段,明细按需查 |
订单取消示例,演示状态条件更新。
package io.github.atengk.module.order.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.module.order.entity.OrderEntity;
import io.github.atengk.module.order.enums.OrderStatusEnum;
import io.github.atengk.module.order.mapper.OrderMapper;
import io.github.atengk.module.order.service.OrderService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 订单服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, OrderEntity> implements OrderService {
/**
* 取消订单
*
* @param orderId 订单ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void cancelOrder(Long orderId) {
boolean updated = this.lambdaUpdate()
.eq(OrderEntity::getId, orderId)
.eq(OrderEntity::getOrderStatus, OrderStatusEnum.WAIT_PAY.getCode())
.set(OrderEntity::getOrderStatus, OrderStatusEnum.CANCELED.getCode())
.set(OrderEntity::getCancelTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("订单不存在或当前状态不允许取消");
}
log.info("取消订单成功,订单ID:{}", orderId);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
库存管理
库存管理用于维护商品库存、库存流水、库存冻结、库存扣减和库存回滚。库存属于高并发敏感模型,不建议只依赖普通 getById -> setStock -> updateById。
库存表:
CREATE TABLE biz_stock (
id BIGINT NOT NULL COMMENT '库存ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
product_id BIGINT NOT NULL DEFAULT 0 COMMENT '商品ID',
stock_count INT NOT NULL DEFAULT 0 COMMENT '可用库存',
frozen_count INT NOT NULL DEFAULT 0 COMMENT '冻结库存',
sold_count INT NOT NULL DEFAULT 0 COMMENT '已售库存',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_product (tenant_id, product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';2
3
4
5
6
7
8
9
10
11
12
13
库存流水表:
CREATE TABLE biz_stock_record (
id BIGINT NOT NULL COMMENT '库存流水ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
product_id BIGINT NOT NULL DEFAULT 0 COMMENT '商品ID',
business_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '业务类型',
business_no VARCHAR(128) NOT NULL DEFAULT '' COMMENT '业务单号',
change_quantity INT NOT NULL DEFAULT 0 COMMENT '变更数量',
before_stock INT NOT NULL DEFAULT 0 COMMENT '变更前库存',
after_stock INT NOT NULL DEFAULT 0 COMMENT '变更后库存',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_biz_no (tenant_id, business_type, business_no),
KEY idx_product_time (product_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
库存管理开发要点:
| 场景 | 建议 |
|---|---|
| 扣减库存 | 使用条件更新:stock_count >= quantity |
| 库存流水 | 每次变更都写流水 |
| 幂等控制 | 使用业务单号唯一索引 |
| 冻结库存 | 下单冻结,支付后转已售,取消后释放 |
| 并发控制 | 条件更新、乐观锁或专门库存服务 |
| 库存查询 | 高频场景考虑缓存,但写入以数据库为准 |
| 对账 | 根据库存流水核对库存余额 |
库存扣减示例:
package io.github.atengk.module.stock.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.module.stock.entity.StockEntity;
import io.github.atengk.module.stock.mapper.StockMapper;
import io.github.atengk.module.stock.service.StockService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 库存服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity> implements StockService {
/**
* 扣减库存
*
* @param tenantId 租户ID
* @param productId 商品ID
* @param quantity 扣减数量
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void deductStock(Long tenantId, Long productId, Integer quantity) {
if (quantity == null || quantity <= 0) {
throw new BusinessException("扣减数量必须大于0");
}
boolean updated = this.lambdaUpdate()
.eq(StockEntity::getTenantId, tenantId)
.eq(StockEntity::getProductId, productId)
.ge(StockEntity::getStockCount, quantity)
.setSql("stock_count = stock_count - " + quantity)
.setSql("sold_count = sold_count + " + quantity)
.update();
if (!updated) {
throw new BusinessException("库存不足");
}
log.info("扣减库存成功,租户ID:{},商品ID:{},数量:{}", tenantId, productId, quantity);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
上面示例中的 quantity 已由后端校验为正整数后再用于 setSql。更严谨的高并发库存系统可使用 XML 参数绑定、库存服务、消息队列或 Redis 预扣方案。
审批流程
审批流程用于处理请假、报销、合同、采购、订单审核等业务。简单系统可以自研流程表;复杂系统建议使用 Flowable、Activiti、Camunda 等流程引擎。
简单审批单表:
CREATE TABLE biz_approval (
id BIGINT NOT NULL COMMENT '审批单ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
approval_no VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批单号',
business_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '业务类型',
business_id BIGINT NOT NULL DEFAULT 0 COMMENT '业务ID',
title VARCHAR(200) NOT NULL DEFAULT '' COMMENT '审批标题',
apply_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '申请人ID',
apply_dept_id BIGINT NOT NULL DEFAULT 0 COMMENT '申请部门ID',
approval_status TINYINT NOT NULL DEFAULT 10 COMMENT '审批状态:10待提交,20审批中,30通过,40驳回,50撤回',
current_node_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '当前节点名称',
submit_time DATETIME NULL COMMENT '提交时间',
finish_time DATETIME NULL COMMENT '完成时间',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_approval_no (tenant_id, approval_no),
KEY idx_apply_user_time (apply_user_id, create_time),
KEY idx_status_time (approval_status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批单表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
审批记录表:
CREATE TABLE biz_approval_record (
id BIGINT NOT NULL COMMENT '审批记录ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
approval_id BIGINT NOT NULL DEFAULT 0 COMMENT '审批单ID',
node_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '节点名称',
approver_id BIGINT NOT NULL DEFAULT 0 COMMENT '审批人ID',
approval_action TINYINT NOT NULL DEFAULT 0 COMMENT '审批动作:1通过,2驳回,3撤回',
approval_comment VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '审批意见',
approval_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '审批时间',
PRIMARY KEY (id),
KEY idx_approval_id (approval_id),
KEY idx_approver_time (approver_id, approval_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批记录表';2
3
4
5
6
7
8
9
10
11
12
13
审批流程开发要点:
| 场景 | 建议 |
|---|---|
| 提交审批 | 草稿状态才允许提交 |
| 审批通过 | 校验当前审批人是否有权限 |
| 驳回审批 | 记录审批意见 |
| 撤回审批 | 只有申请人且审批中状态允许撤回 |
| 状态流转 | 使用状态机或明确条件更新 |
| 并发审批 | 使用乐观锁或条件更新 |
| 审批记录 | 每次动作必须留痕 |
| 业务回写 | 审批通过后更新业务单据状态 |
统计报表
统计报表用于对业务数据做汇总、分组、趋势、排行、看板展示。报表不建议直接高频扫描业务主表,尤其是订单、日志、库存流水等大表。
常见报表模型:
| 报表 | 数据来源 | 建议 |
|---|---|---|
| 用户增长趋势 | sys_user | 小数据量可实时统计 |
| 订单金额统计 | biz_order | 建议日汇总表 |
| 商品销量排行 | biz_order_item | 建议异步汇总 |
| 库存变更统计 | biz_stock_record | 建议按日汇总 |
| 登录失败统计 | sys_login_log | 可按小时或天汇总 |
| 操作日志统计 | sys_operation_log | 大表建议分表或归档 |
订单日汇总表:
CREATE TABLE rpt_order_day (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
stat_date DATE NOT NULL COMMENT '统计日期',
order_count BIGINT NOT NULL DEFAULT 0 COMMENT '订单数量',
pay_order_count BIGINT NOT NULL DEFAULT 0 COMMENT '支付订单数量',
cancel_order_count BIGINT NOT NULL DEFAULT 0 COMMENT '取消订单数量',
total_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '订单总金额',
pay_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '支付金额',
refund_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '退款金额',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_stat_date (tenant_id, stat_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单日统计表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
统计报表开发要点:
| 场景 | 建议 |
|---|---|
| 小数据量临时报表 | 可以直接 XML 聚合查询 |
| 高频看板 | 使用汇总表 |
| 大数据量统计 | 异步任务或报表库 |
| 排行榜 | 使用窗口函数或汇总表 |
| 时间趋势 | 按天、小时预聚合 |
| 导出报表 | 异步导出并限制范围 |
| 数据权限 | 报表也要受租户和数据权限控制 |
| COUNT 查询 | 复杂报表避免实时 COUNT 大表 |
日统计任务示例:
package io.github.atengk.module.report.job;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import io.github.atengk.module.report.service.OrderReportService;
import io.github.atengk.module.system.tenant.entity.TenantEntity;
import io.github.atengk.module.system.tenant.service.TenantService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
/**
* 订单报表统计任务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderReportStatisticsJob {
private final TenantService tenantService;
private final OrderReportService orderReportService;
private final CurrentTenantContext currentTenantContext;
/**
* 每天凌晨统计昨日订单数据
*/
@Scheduled(cron = "0 10 2 * * ?")
public void statisticsYesterdayOrder() {
LocalDate statDate = LocalDate.now().minusDays(1);
for (TenantEntity tenant : tenantService.listEnabledTenants()) {
try {
currentTenantContext.setTenantId(tenant.getId());
orderReportService.statisticsOrderDay(tenant.getId(), statDate);
log.info("订单日报统计完成,租户ID:{},统计日期:{}", tenant.getId(), statDate);
} catch (Exception exception) {
log.error("订单日报统计失败,租户ID:{},统计日期:{}", tenant.getId(), statDate, exception);
} finally {
currentTenantContext.clear();
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
常见业务模型整体设计建议
这些业务模型之间通常不是孤立的。用户、角色、菜单、部门、岗位构成权限和组织模型;字典、参数、文件、日志构成基础支撑能力;订单、库存、审批、报表构成业务核心链路。
整体建议如下:
| 模型类型 | 开发重点 |
|---|---|
| 权限模型 | 用户、角色、菜单、数据范围、权限缓存 |
| 组织模型 | 部门、岗位、负责人、上下级关系 |
| 基础配置 | 字典、参数、文件、公告 |
| 审计模型 | 操作日志、登录日志、审批记录 |
| 交易模型 | 订单、库存、流水、幂等、事务 |
| 报表模型 | 汇总表、异步统计、数据权限 |
| 多租户模型 | 所有业务表统一 tenant_id |
| 数据权限模型 | 用户、部门、角色、自定义范围联动 |
开发时优先确定每个模型的“状态字段、唯一约束、删除规则、权限范围、事务边界、缓存策略、日志策略”。这些规则比单纯生成 CRUD 更重要。
树形数据处理
树形数据常用于部门、菜单、分类、区域、组织架构等模型。树形数据的核心问题不是“如何查出来”,而是如何设计父子关系、如何维护祖级路径、如何防止循环引用、如何高效组装树、如何安全删除节点以及如何处理排序。
父子表结构设计
父子表结构是树形数据最基础的设计方式。每条记录保存自己的 id 和 parent_id,根节点的 parent_id 通常为 0。这种结构简单、直观,适合大多数后台管理系统。
部门表结构示例:
CREATE TABLE sys_dept (
id BIGINT NOT NULL COMMENT '部门ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父部门ID',
ancestors VARCHAR(500) NOT NULL DEFAULT '' COMMENT '祖级路径,例如 0,1001,1002',
dept_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '部门名称',
dept_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '部门编码',
leader_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '负责人用户ID',
phone VARCHAR(20) NOT NULL DEFAULT '' COMMENT '联系电话',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_dept_code (tenant_id, dept_code),
KEY idx_tenant_parent (tenant_id, parent_id),
KEY idx_tenant_ancestors (tenant_id, ancestors),
KEY idx_tenant_status_sort (tenant_id, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统部门表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
父子结构字段建议:
| 字段 | 说明 |
|---|---|
id | 当前节点 ID |
parent_id | 父节点 ID,根节点为 0 |
ancestors | 祖级路径,用于快速判断上下级关系 |
sort_order | 同级排序 |
status | 节点状态 |
tenant_id | 多租户隔离字段 |
deleted | 逻辑删除字段 |
树形实体示例:
文件位置:src/main/java/io/github/atengk/module/system/dept/entity/DeptEntity.java
package io.github.atengk.module.system.dept.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import io.github.atengk.common.entity.TenantBaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 部门实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_dept")
public class DeptEntity extends TenantBaseEntity {
/**
* 父部门 ID
*/
private Long parentId;
/**
* 祖级路径,例如 0,1001,1002
*/
private String ancestors;
/**
* 部门名称
*/
private String deptName;
/**
* 部门编码
*/
private String deptCode;
/**
* 负责人用户 ID
*/
private Long leaderUserId;
/**
* 联系电话
*/
private String phone;
/**
* 排序
*/
private Integer sortOrder;
/**
* 状态:0禁用,1启用
*/
private Integer status;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
父子表结构适合增删改查频繁、层级不深的数据。若树层级很深、跨层级查询很频繁,可以增加 ancestors 字段、闭包表或路径枚举等辅助结构。
祖级路径设计
祖级路径用于记录当前节点所有上级节点 ID,常见格式为 0,1001,1002。例如节点 1003 的父节点是 1002,1002 的父节点是 1001,则 1003 的 ancestors 可以是 0,1001,1002。
祖级路径的作用:
| 场景 | 作用 |
|---|---|
| 查询所有子节点 | 使用 ancestors LIKE '%,当前ID,%' 或路径匹配 |
| 判断上下级关系 | 判断目标节点 ancestors 是否包含当前节点 |
| 防止父级改成子级 | 修改父节点时校验 |
| 数据权限 | 本部门及下级部门范围计算 |
| 面包屑路径 | 可根据 ancestors 批量查询上级名称 |
新增节点时,祖级路径由父节点决定:
父节点 ancestors = 0,1001
父节点 id = 1002
当前节点 ancestors = 0,1001,10022
3
祖级路径工具方法可以统一封装。
文件位置:src/main/java/io/github/atengk/module/system/dept/support/DeptAncestorsHelper.java
package io.github.atengk.module.system.dept.support;
import cn.hutool.core.util.StrUtil;
/**
* 部门祖级路径辅助工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class DeptAncestorsHelper {
private static final String ROOT_ANCESTORS = "0";
private DeptAncestorsHelper() {
}
/**
* 构建子节点祖级路径
*
* @param parentAncestors 父节点祖级路径
* @param parentId 父节点 ID
* @return 子节点祖级路径
*/
public static String buildChildAncestors(String parentAncestors, Long parentId) {
if (parentId == null || parentId == 0L) {
return ROOT_ANCESTORS;
}
String safeParentAncestors = StrUtil.blankToDefault(parentAncestors, ROOT_ANCESTORS);
return safeParentAncestors + "," + parentId;
}
/**
* 判断祖级路径是否包含指定节点 ID
*
* @param ancestors 祖级路径
* @param nodeId 节点 ID
* @return 是否包含
*/
public static boolean containsNode(String ancestors, Long nodeId) {
if (StrUtil.isBlank(ancestors) || nodeId == null) {
return false;
}
String wrappedAncestors = "," + ancestors + ",";
String wrappedNodeId = "," + nodeId + ",";
return StrUtil.contains(wrappedAncestors, wrappedNodeId);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
祖级路径设计建议:
| 规范项 | 建议 |
|---|---|
| 根节点路径 | 统一使用 0 |
| 分隔符 | 使用英文逗号 |
| 字段长度 | 根据最大层级预估,常用 VARCHAR(500) |
| 修改父节点 | 必须同步更新子孙节点 ancestors |
| 查询子树 | 数据量较小时可用 LIKE,大数据量需谨慎 |
| 多租户 | 所有查询都必须带 tenant_id |
| 数据修复 | 可通过递归脚本重建 ancestors |
递归查询
递归查询是指从某个节点开始,查询它的所有子节点或所有父节点。实现方式有两类:应用层递归和数据库递归。应用层递归适合数据量较小或已一次性加载全量节点的场景;数据库递归适合数据库支持递归 CTE 且希望在数据库层完成子树查询的场景。
应用层递归查询通常流程:
- 查询当前租户下所有有效节点。
- 根据
parent_id分组。 - 从目标节点开始递归收集子节点。
- 返回子节点列表或树结构。
递归收集子节点工具方法如下。
文件位置:src/main/java/io/github/atengk/common/tree/TreeRecursionUtil.java
package io.github.atengk.common.tree;
import cn.hutool.core.collection.CollUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 树形递归工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class TreeRecursionUtil {
private TreeRecursionUtil() {
}
/**
* 递归收集所有子节点
*
* @param records 全量节点
* @param parentId 父节点 ID
* @param <T> 节点类型
* @return 子节点列表
*/
public static <T extends TreeNode<T>> List<T> listChildren(List<T> records, Long parentId) {
if (CollUtil.isEmpty(records)) {
return List.of();
}
Map<Long, List<T>> parentMap = records.stream()
.collect(Collectors.groupingBy(TreeNode::getParentId));
List<T> result = new ArrayList<>();
collectChildren(parentMap, parentId, result);
return result;
}
/**
* 递归收集子节点
*
* @param parentMap 父节点映射
* @param parentId 父节点 ID
* @param result 结果列表
* @param <T> 节点类型
*/
private static <T extends TreeNode<T>> void collectChildren(Map<Long, List<T>> parentMap,
Long parentId,
List<T> result) {
List<T> children = parentMap.get(parentId);
if (CollUtil.isEmpty(children)) {
return;
}
for (T child : children) {
result.add(child);
collectChildren(parentMap, child.getId(), result);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
树节点接口如下,用于统一树结构组装和递归处理。
文件位置:src/main/java/io/github/atengk/common/tree/TreeNode.java
package io.github.atengk.common.tree;
import java.util.List;
/**
* 树节点基础接口
*
* @author Ateng
* @since 2026-05-05
*/
public interface TreeNode<T extends TreeNode<T>> {
/**
* 获取节点 ID
*
* @return 节点 ID
*/
Long getId();
/**
* 获取父节点 ID
*
* @return 父节点 ID
*/
Long getParentId();
/**
* 获取排序值
*
* @return 排序值
*/
Integer getSortOrder();
/**
* 设置子节点
*
* @param children 子节点列表
*/
void setChildren(List<T> children);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
递归查询注意事项:
| 场景 | 建议 |
|---|---|
| 节点数量较少 | 一次性查出,内存递归 |
| 节点数量较大 | 使用数据库递归或 ancestors |
| 层级较深 | 注意递归深度 |
| 频繁查询子树 | 使用 ancestors 或缓存 |
| 菜单树 | 通常内存组装即可 |
| 部门树 | 通常内存组装或 ancestors 查询 |
| 区域树 | 可缓存,不频繁查数据库 |
内存组装树
内存组装树是最常用的树形处理方式。它先查出节点列表,再按 parent_id 分组,最后从根节点开始挂载子节点。相比数据库递归,内存组装更容易排序、过滤、转换 VO,也更适合菜单树、部门树、分类树等后台管理场景。
树返回 VO:
文件位置:src/main/java/io/github/atengk/module/system/dept/vo/DeptTreeVO.java
package io.github.atengk.module.system.dept.vo;
import io.github.atengk.common.tree.TreeNode;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 部门树返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class DeptTreeVO implements TreeNode<DeptTreeVO> {
/**
* 部门 ID
*/
private Long id;
/**
* 父部门 ID
*/
private Long parentId;
/**
* 部门名称
*/
private String deptName;
/**
* 部门编码
*/
private String deptCode;
/**
* 排序
*/
private Integer sortOrder;
/**
* 状态:0禁用,1启用
*/
private Integer status;
/**
* 子部门
*/
private List<DeptTreeVO> children;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
树构建工具如下。
文件位置:src/main/java/io/github/atengk/common/tree/TreeBuilder.java
package io.github.atengk.common.tree;
import cn.hutool.core.collection.CollUtil;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 树结构构建工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class TreeBuilder {
private TreeBuilder() {
}
/**
* 构建树结构
*
* @param records 节点列表
* @param rootParentId 根节点父 ID
* @param <T> 节点类型
* @return 树结构列表
*/
public static <T extends TreeNode<T>> List<T> build(List<T> records, Long rootParentId) {
if (CollUtil.isEmpty(records)) {
return List.of();
}
Map<Long, List<T>> parentMap = records.stream()
.collect(Collectors.groupingBy(TreeNode::getParentId));
records.forEach(node -> {
List<T> children = parentMap.getOrDefault(node.getId(), List.of())
.stream()
.sorted(getSortComparator())
.toList();
node.setChildren(children);
});
return parentMap.getOrDefault(rootParentId, List.of())
.stream()
.sorted(getSortComparator())
.toList();
}
/**
* 获取排序比较器
*
* @param <T> 节点类型
* @return 排序比较器
*/
private static <T extends TreeNode<T>> Comparator<T> getSortComparator() {
return Comparator.comparing(
TreeNode::getSortOrder,
Comparator.nullsLast(Integer::compareTo)
);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
Service 查询部门树:
@Override
public List<DeptTreeVO> listDeptTree() {
List<DeptEntity> entities = this.lambdaQuery()
.orderByAsc(DeptEntity::getSortOrder)
.orderByAsc(DeptEntity::getId)
.list();
if (CollUtil.isEmpty(entities)) {
return List.of();
}
List<DeptTreeVO> records = deptConvert.toTreeVOList(entities);
return TreeBuilder.build(records, 0L);
}2
3
4
5
6
7
8
9
10
11
12
13
14
内存组装树建议:
| 场景 | 建议 |
|---|---|
| 菜单树 | 推荐 |
| 部门树 | 推荐 |
| 字典分类树 | 推荐 |
| 区域树 | 可缓存后组装 |
| 超大树 | 不建议一次性加载 |
| 需要数据权限 | 先过滤可见节点,再组装 |
| 需要禁用过滤 | 查询时过滤或组装后过滤 |
数据库递归查询
数据库递归查询适合直接查询某个节点的所有子节点。MySQL 8 支持递归 CTE,可以使用 WITH RECURSIVE 实现。该方式适合查询子树列表、删除前检查子节点、统计子节点数量等场景。
Mapper 接口:
文件位置:src/main/java/io/github/atengk/module/system/dept/mapper/DeptMapper.java
package io.github.atengk.module.system.dept.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.atengk.module.system.dept.entity.DeptEntity;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 部门 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface DeptMapper extends BaseMapper<DeptEntity> {
/**
* 递归查询子部门列表
*
* @param tenantId 租户 ID
* @param parentId 父部门 ID
* @return 子部门列表
*/
List<DeptEntity> selectChildrenRecursive(@Param("tenantId") Long tenantId,
@Param("parentId") Long parentId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
XML 使用 MySQL 8 递归 CTE 查询所有子节点。
文件位置:src/main/resources/mapper/system/DeptMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.atengk.module.system.dept.mapper.DeptMapper">
<select id="selectChildrenRecursive" resultType="io.github.atengk.module.system.dept.entity.DeptEntity">
WITH RECURSIVE dept_tree AS (
SELECT
id,
tenant_id,
parent_id,
ancestors,
dept_name,
dept_code,
leader_user_id,
phone,
sort_order,
status,
create_by,
create_time,
update_by,
update_time,
deleted,
version
FROM sys_dept
WHERE tenant_id = #{tenantId}
AND parent_id = #{parentId}
AND deleted = 0
UNION ALL
SELECT
d.id,
d.tenant_id,
d.parent_id,
d.ancestors,
d.dept_name,
d.dept_code,
d.leader_user_id,
d.phone,
d.sort_order,
d.status,
d.create_by,
d.create_time,
d.update_by,
d.update_time,
d.deleted,
d.version
FROM sys_dept d
INNER JOIN dept_tree t ON d.parent_id = t.id
WHERE d.tenant_id = #{tenantId}
AND d.deleted = 0
)
SELECT
id,
tenant_id,
parent_id,
ancestors,
dept_name,
dept_code,
leader_user_id,
phone,
sort_order,
status,
create_by,
create_time,
update_by,
update_time,
deleted,
version
FROM dept_tree
ORDER BY sort_order ASC, id ASC
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
数据库递归查询建议:
| 场景 | 建议 |
|---|---|
| MySQL 8+ | 可以使用 WITH RECURSIVE |
| MySQL 5.7 | 不支持递归 CTE,使用 ancestors 或应用层递归 |
| 查询子树 | 可用 |
| 统计子节点数量 | 可用 |
| 复杂树组装 | 仍建议应用层组装 |
| 大树递归 | 注意递归深度和执行计划 |
| 多租户 | 必须带 tenant_id 条件 |
树形新增
树形新增需要确定父节点是否存在、父节点是否启用、当前节点编码是否唯一,并根据父节点生成当前节点的 ancestors。根节点的 parent_id 通常为 0,ancestors 为 0。
新增 DTO:
文件位置:src/main/java/io/github/atengk/module/system/dept/dto/DeptAddDTO.java
package io.github.atengk.module.system.dept.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 部门新增参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class DeptAddDTO {
/**
* 父部门 ID,根节点传 0
*/
@NotNull(message = "父部门ID不能为空")
private Long parentId;
/**
* 部门名称
*/
@NotBlank(message = "部门名称不能为空")
@Size(max = 64, message = "部门名称长度不能超过64个字符")
private String deptName;
/**
* 部门编码
*/
@NotBlank(message = "部门编码不能为空")
@Size(max = 64, message = "部门编码长度不能超过64个字符")
private String deptCode;
/**
* 负责人用户 ID
*/
private Long leaderUserId;
/**
* 联系电话
*/
@Size(max = 20, message = "联系电话长度不能超过20个字符")
private String phone;
/**
* 排序
*/
private Integer sortOrder;
/**
* 状态:0禁用,1启用
*/
private Integer status;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
树形新增 Service 示例:
@Transactional(rollbackFor = Exception.class)
public Long addDept(DeptAddDTO dto) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
checkDeptCodeUnique(tenantId, dto.getDeptCode(), null);
DeptEntity parent = getParentOrRoot(tenantId, dto.getParentId());
String ancestors = DeptAncestorsHelper.buildChildAncestors(
parent == null ? null : parent.getAncestors(),
dto.getParentId()
);
DeptEntity entity = deptConvert.toEntity(dto);
entity.setTenantId(tenantId);
entity.setAncestors(ancestors);
entity.setSortOrder(ObjectUtil.defaultIfNull(entity.getSortOrder(), 0));
entity.setStatus(ObjectUtil.defaultIfNull(entity.getStatus(), 1));
boolean saved = this.save(entity);
if (!saved) {
throw new BusinessException("新增部门失败");
}
log.info("新增部门成功,租户ID:{},部门ID:{},父部门ID:{}", tenantId, entity.getId(), entity.getParentId());
return entity.getId();
}
/**
* 获取父部门,根节点返回 null
*
* @param tenantId 租户 ID
* @param parentId 父部门 ID
* @return 父部门
*/
private DeptEntity getParentOrRoot(Long tenantId, Long parentId) {
if (parentId == null || parentId == 0L) {
return null;
}
DeptEntity parent = this.lambdaQuery()
.eq(DeptEntity::getTenantId, tenantId)
.eq(DeptEntity::getId, parentId)
.one();
if (parent == null) {
throw new BusinessException("父部门不存在");
}
if (ObjectUtil.notEqual(parent.getStatus(), 1)) {
throw new BusinessException("父部门已禁用,不能新增子部门");
}
return parent;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
树形新增建议:
| 检查项 | 建议 |
|---|---|
| 父节点 | 必须存在,根节点除外 |
| 父节点状态 | 禁用父节点下通常不允许新增 |
| 节点编码 | 同租户唯一 |
| 祖级路径 | 根据父节点生成 |
| 排序值 | 未传时默认 0 |
| 租户 ID | 从上下文获取 |
| 数据权限 | 只能在有权限的父节点下新增 |
树形修改
树形修改比普通修改复杂,因为修改父节点可能影响当前节点及所有子孙节点的 ancestors。同时必须防止把父节点改成自己或自己的子节点,否则会形成循环树。
修改 DTO:
文件位置:src/main/java/io/github/atengk/module/system/dept/dto/DeptUpdateDTO.java
package io.github.atengk.module.system.dept.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 部门修改参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class DeptUpdateDTO {
/**
* 部门 ID
*/
@NotNull(message = "部门ID不能为空")
private Long id;
/**
* 父部门 ID
*/
@NotNull(message = "父部门ID不能为空")
private Long parentId;
/**
* 部门名称
*/
@NotBlank(message = "部门名称不能为空")
@Size(max = 64, message = "部门名称长度不能超过64个字符")
private String deptName;
/**
* 部门编码
*/
@NotBlank(message = "部门编码不能为空")
@Size(max = 64, message = "部门编码长度不能超过64个字符")
private String deptCode;
/**
* 负责人用户 ID
*/
private Long leaderUserId;
/**
* 联系电话
*/
@Size(max = 20, message = "联系电话长度不能超过20个字符")
private String phone;
/**
* 排序
*/
private Integer sortOrder;
/**
* 状态:0禁用,1启用
*/
private Integer status;
/**
* 乐观锁版本号
*/
@NotNull(message = "版本号不能为空")
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
树形修改 Service 示例,包含父节点变更后的子孙节点 ancestors 同步。
@Transactional(rollbackFor = Exception.class)
public void updateDept(DeptUpdateDTO dto) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
DeptEntity oldEntity = this.lambdaQuery()
.eq(DeptEntity::getTenantId, tenantId)
.eq(DeptEntity::getId, dto.getId())
.one();
if (oldEntity == null) {
throw new BusinessException("部门不存在");
}
if (ObjectUtil.equals(dto.getId(), dto.getParentId())) {
throw new BusinessException("父部门不能是当前部门");
}
checkDeptCodeUnique(tenantId, dto.getDeptCode(), dto.getId());
checkParentNotChild(tenantId, dto.getId(), dto.getParentId());
DeptEntity parent = getParentOrRoot(tenantId, dto.getParentId());
String newAncestors = DeptAncestorsHelper.buildChildAncestors(
parent == null ? null : parent.getAncestors(),
dto.getParentId()
);
DeptEntity entity = deptConvert.toEntity(dto);
entity.setTenantId(tenantId);
entity.setAncestors(newAncestors);
boolean updated = this.updateById(entity);
if (!updated) {
throw new BusinessException("修改部门失败,请刷新后重试");
}
if (ObjectUtil.notEqual(oldEntity.getAncestors(), newAncestors)) {
updateChildrenAncestors(tenantId, oldEntity.getId(), oldEntity.getAncestors(), newAncestors);
}
log.info("修改部门成功,租户ID:{},部门ID:{},新父部门ID:{}", tenantId, dto.getId(), dto.getParentId());
}
/**
* 校验新父节点不是当前节点的子节点
*
* @param tenantId 租户 ID
* @param currentId 当前部门 ID
* @param newParentId 新父部门 ID
*/
private void checkParentNotChild(Long tenantId, Long currentId, Long newParentId) {
if (newParentId == null || newParentId == 0L) {
return;
}
DeptEntity newParent = this.lambdaQuery()
.eq(DeptEntity::getTenantId, tenantId)
.eq(DeptEntity::getId, newParentId)
.one();
if (newParent == null) {
throw new BusinessException("父部门不存在");
}
if (DeptAncestorsHelper.containsNode(newParent.getAncestors(), currentId)) {
throw new BusinessException("父部门不能是当前部门的子部门");
}
}
/**
* 更新子孙节点祖级路径
*
* @param tenantId 租户 ID
* @param deptId 部门 ID
* @param oldAncestors 旧祖级路径
* @param newAncestors 新祖级路径
*/
private void updateChildrenAncestors(Long tenantId, Long deptId, String oldAncestors, String newAncestors) {
List<DeptEntity> children = this.lambdaQuery()
.eq(DeptEntity::getTenantId, tenantId)
.like(DeptEntity::getAncestors, deptId)
.list();
if (CollUtil.isEmpty(children)) {
return;
}
String oldPrefix = oldAncestors + "," + deptId;
String newPrefix = newAncestors + "," + deptId;
for (DeptEntity child : children) {
String childAncestors = child.getAncestors();
if (StrUtil.startWith(childAncestors, oldPrefix)) {
child.setAncestors(StrUtil.replace(childAncestors, oldPrefix, newPrefix));
}
}
this.updateBatchById(children, 1000);
log.info("同步更新子部门祖级路径成功,租户ID:{},部门ID:{},子节点数量:{}", tenantId, deptId, children.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
树形修改建议:
| 场景 | 建议 |
|---|---|
| 修改名称 | 普通更新 |
| 修改排序 | 普通更新 |
| 修改父节点 | 必须校验循环引用 |
| 修改父节点 | 必须同步子孙节点 ancestors |
| 禁用节点 | 应校验是否存在启用子节点 |
| 修改编码 | 校验唯一性 |
| 并发修改 | 使用乐观锁 version |
树形删除
树形删除需要先判断是否存在子节点、是否存在关联业务数据。一般不建议直接级联删除整棵树,尤其是部门、菜单、分类这类被业务数据引用的模型。
删除策略通常有三种:
| 策略 | 说明 | 建议 |
|---|---|---|
| 禁止删除有子节点的节点 | 最安全 | 推荐 |
| 删除节点及全部子节点 | 风险高 | 谨慎 |
| 只逻辑删除当前节点 | 可能产生孤儿节点 | 不推荐 |
删除部门示例:
@Transactional(rollbackFor = Exception.class)
public void deleteDept(Long id) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
DeptEntity entity = this.lambdaQuery()
.eq(DeptEntity::getTenantId, tenantId)
.eq(DeptEntity::getId, id)
.one();
if (entity == null) {
throw new BusinessException("部门不存在");
}
checkNoChildren(tenantId, id);
checkNoUserBound(tenantId, id);
boolean removed = this.removeById(id);
if (!removed) {
throw new BusinessException("删除部门失败");
}
log.info("删除部门成功,租户ID:{},部门ID:{}", tenantId, id);
}
/**
* 校验不存在子部门
*
* @param tenantId 租户 ID
* @param deptId 部门 ID
*/
private void checkNoChildren(Long tenantId, Long deptId) {
long childCount = this.lambdaQuery()
.eq(DeptEntity::getTenantId, tenantId)
.eq(DeptEntity::getParentId, deptId)
.count();
if (childCount > 0) {
throw new BusinessException("当前部门存在子部门,不能删除");
}
}
/**
* 校验部门未绑定用户
*
* @param tenantId 租户 ID
* @param deptId 部门 ID
*/
private void checkNoUserBound(Long tenantId, Long deptId) {
long userCount = userService.lambdaQuery()
.eq(UserEntity::getTenantId, tenantId)
.eq(UserEntity::getDeptId, deptId)
.count();
if (userCount > 0) {
throw new BusinessException("当前部门下存在用户,不能删除");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
如果确实需要删除整棵子树,应先查出所有子节点 ID,再批量校验业务引用,最后批量逻辑删除。
@Transactional(rollbackFor = Exception.class)
public void deleteDeptTree(Long id) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
List<DeptEntity> children = baseMapper.selectChildrenRecursive(tenantId, id);
List<Long> deleteIds = new java.util.ArrayList<>();
deleteIds.add(id);
if (CollUtil.isNotEmpty(children)) {
deleteIds.addAll(children.stream().map(DeptEntity::getId).toList());
}
checkNoUsersBound(tenantId, deleteIds);
boolean removed = this.removeBatchByIds(deleteIds, 1000);
if (!removed) {
throw new BusinessException("删除部门树失败");
}
log.info("删除部门树成功,租户ID:{},根部门ID:{},删除数量:{}", tenantId, id, deleteIds.size());
}
/**
* 校验多个部门未绑定用户
*
* @param tenantId 租户 ID
* @param deptIds 部门 ID 列表
*/
private void checkNoUsersBound(Long tenantId, List<Long> deptIds) {
long userCount = userService.lambdaQuery()
.eq(UserEntity::getTenantId, tenantId)
.in(UserEntity::getDeptId, deptIds)
.count();
if (userCount > 0) {
throw new BusinessException("待删除部门或子部门下存在用户,不能删除");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
树形删除建议:
| 场景 | 建议 |
|---|---|
| 部门删除 | 有子部门或用户时禁止删除 |
| 菜单删除 | 有子菜单时禁止删除 |
| 分类删除 | 有商品或子分类时禁止删除 |
| 区域删除 | 通常不允许删除基础区域 |
| 级联删除 | 必须记录操作日志 |
| 批量删除 | 校验所有节点和子节点引用 |
| 逻辑删除 | 优先使用逻辑删除 |
子节点校验
子节点校验用于新增、修改、删除、禁用等操作前判断节点关系是否合法。常见校验包括:是否存在子节点、是否存在启用子节点、目标父节点是否是当前节点的子节点、是否存在业务引用。
常用校验方法:
/**
* 判断是否存在子节点
*
* @param tenantId 租户 ID
* @param parentId 父节点 ID
* @return 是否存在
*/
private boolean hasChildren(Long tenantId, Long parentId) {
return this.lambdaQuery()
.eq(DeptEntity::getTenantId, tenantId)
.eq(DeptEntity::getParentId, parentId)
.exists();
}
/**
* 判断是否存在启用子节点
*
* @param tenantId 租户 ID
* @param parentId 父节点 ID
* @return 是否存在
*/
private boolean hasEnabledChildren(Long tenantId, Long parentId) {
return this.lambdaQuery()
.eq(DeptEntity::getTenantId, tenantId)
.eq(DeptEntity::getParentId, parentId)
.eq(DeptEntity::getStatus, 1)
.exists();
}
/**
* 校验部门编码唯一
*
* @param tenantId 租户 ID
* @param deptCode 部门编码
* @param excludeId 排除的部门 ID
*/
private void checkDeptCodeUnique(Long tenantId, String deptCode, Long excludeId) {
long count = this.lambdaQuery()
.eq(DeptEntity::getTenantId, tenantId)
.eq(DeptEntity::getDeptCode, deptCode)
.ne(excludeId != null, DeptEntity::getId, excludeId)
.count();
if (count > 0) {
throw new BusinessException("部门编码已存在");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
禁用部门时校验启用子部门:
@Transactional(rollbackFor = Exception.class)
public void disableDept(Long id) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
if (hasEnabledChildren(tenantId, id)) {
throw new BusinessException("当前部门存在启用的子部门,不能禁用");
}
boolean updated = this.lambdaUpdate()
.eq(DeptEntity::getTenantId, tenantId)
.eq(DeptEntity::getId, id)
.set(DeptEntity::getStatus, 0)
.update();
if (!updated) {
throw new BusinessException("禁用部门失败");
}
log.info("禁用部门成功,租户ID:{},部门ID:{}", tenantId, id);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
子节点校验建议:
| 操作 | 校验内容 |
|---|---|
| 新增节点 | 父节点是否存在、是否启用 |
| 修改父节点 | 不能选择自己或子节点 |
| 删除节点 | 是否存在子节点、是否存在业务引用 |
| 禁用节点 | 是否存在启用子节点 |
| 启用节点 | 父节点是否启用 |
| 移动节点 | 是否导致循环引用 |
| 批量操作 | 所有节点都要校验 |
排序处理
树形数据排序通常使用 sort_order 字段,排序范围一般是同一父节点下的兄弟节点。查询树时先按 sort_order ASC,再按 id ASC 兜底,保证排序稳定。
查询排序:
List<DeptEntity> entities = this.lambdaQuery()
.eq(DeptEntity::getTenantId, tenantId)
.orderByAsc(DeptEntity::getParentId)
.orderByAsc(DeptEntity::getSortOrder)
.orderByAsc(DeptEntity::getId)
.list();2
3
4
5
6
调整排序 DTO:
文件位置:src/main/java/io/github/atengk/module/system/dept/dto/DeptSortDTO.java
package io.github.atengk.module.system.dept.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
/**
* 部门排序参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class DeptSortDTO {
/**
* 部门 ID
*/
@NotNull(message = "部门ID不能为空")
private Long id;
/**
* 排序值
*/
@NotNull(message = "排序值不能为空")
private Integer sortOrder;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
批量调整排序:
@Transactional(rollbackFor = Exception.class)
public void updateDeptSort(List<DeptSortDTO> dtoList) {
if (CollUtil.isEmpty(dtoList)) {
throw new BusinessException("排序参数不能为空");
}
Long tenantId = currentUserContext.getTenantIdOrDefault();
List<DeptEntity> entities = dtoList.stream()
.map(dto -> {
DeptEntity entity = new DeptEntity();
entity.setId(dto.getId());
entity.setTenantId(tenantId);
entity.setSortOrder(dto.getSortOrder());
return entity;
})
.toList();
boolean updated = this.updateBatchById(entities, 1000);
if (!updated) {
throw new BusinessException("更新部门排序失败");
}
log.info("更新部门排序成功,租户ID:{},数量:{}", tenantId, entities.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
拖拽移动节点时,通常会同时修改 parent_id 和 sort_order。这种场景本质上是“树形修改”,必须执行循环引用校验和 ancestors 同步。
排序处理建议:
| 场景 | 建议 |
|---|---|
| 同级排序 | 使用 sort_order |
| 查询兜底 | sort_order ASC, id ASC |
| 拖拽排序 | 批量更新兄弟节点排序 |
| 跨父节点拖拽 | 同时修改父节点和 ancestors |
| 排序值重复 | 允许重复,但用 ID 兜底 |
| 排序值为空 | 默认 0 |
| 高频排序 | 控制更新范围,只更新受影响节点 |
树形数据处理建议
树形数据的关键是保持结构合法。只要允许修改父节点,就必须处理循环引用和子孙节点路径同步;只要允许删除节点,就必须处理子节点和业务引用校验。
整体建议如下:
| 能力 | 推荐做法 |
|---|---|
| 表结构 | id + parent_id + ancestors + sort_order |
| 根节点 | parent_id = 0,ancestors = 0 |
| 查询树 | 小中型数据使用内存组装 |
| 查询子树 | 可使用 ancestors 或递归 CTE |
| 新增节点 | 根据父节点生成 ancestors |
| 修改父节点 | 校验不能选择自己或子节点 |
| 删除节点 | 默认禁止删除有子节点的数据 |
| 禁用节点 | 禁止存在启用子节点 |
| 排序 | 同级排序,使用 sort_order |
| 多租户 | 所有树查询都带 tenant_id |
| 数据权限 | 部门树需结合用户可访问部门范围 |
树形模型不要只考虑展示树,还要考虑新增、移动、删除、禁用、权限过滤、排序和数据修复。真正稳定的树形数据处理,一定要把结构校验放在 Service 层统一完成。
字典与枚举回显
字典与枚举回显用于解决“数据库存编码,接口返回名称”的问题。例如数据库中 status = 1,前端需要展示“启用”;数据库中 gender = 2,前端需要展示“女”。稳定、强业务语义的状态建议使用 Java 枚举;需要后台维护、运营可配置、多语言扩展的数据建议使用字典表。
字典与枚举回显的核心原则是:入库用稳定编码,接口回显用展示名称,查询条件仍然使用编码,不使用中文名称作为业务判断依据。
字典表设计
字典通常分为字典类型表和字典数据表。字典类型表用于定义一类字典,例如 user_status、gender、notice_type;字典数据表用于定义具体选项,例如 1=启用、0=禁用。
字典类型表:
CREATE TABLE sys_dict_type (
id BIGINT NOT NULL COMMENT '字典类型ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
dict_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '字典名称',
dict_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '字典类型',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_dict_type (tenant_id, dict_type),
KEY idx_tenant_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字典类型表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
字典数据表:
CREATE TABLE sys_dict_data (
id BIGINT NOT NULL COMMENT '字典数据ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
dict_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '字典类型',
dict_value VARCHAR(64) NOT NULL DEFAULT '' COMMENT '字典值',
dict_label VARCHAR(128) NOT NULL DEFAULT '' COMMENT '字典标签',
tag_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '标签样式:success、info、warning、danger',
css_class VARCHAR(128) NOT NULL DEFAULT '' COMMENT '自定义样式',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_type_value (tenant_id, dict_type, dict_value),
KEY idx_tenant_type_sort (tenant_id, dict_type, sort_order),
KEY idx_tenant_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字典数据表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
字典表设计建议:
| 字段 | 建议 |
|---|---|
dict_type | 字典类型,例如 user_status |
dict_value | 稳定编码,例如 1、0、male |
dict_label | 展示名称,例如“启用”“禁用” |
tag_type | 前端标签样式 |
sort_order | 下拉框排序 |
status | 禁用后不在新增修改选择项中展示 |
tenant_id | 多租户场景下区分平台字典和租户字典 |
| 唯一索引 | (tenant_id, dict_type, dict_value) |
字典类型和字典值都不建议使用中文。中文名称用于展示,编码用于存储和业务判断。
字典缓存
字典数据读取频率高、修改频率低,适合缓存。缓存维度建议使用 tenantId + dictType。如果有平台公共字典和租户私有字典,可以先查租户字典,不存在时回退平台字典。
缓存 Key 工具类:
package io.github.atengk.common.dict;
import cn.hutool.core.util.StrUtil;
/**
* 字典缓存 Key 工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class DictCacheKey {
private static final String DICT_TYPE_KEY = "dict:type:{}:{}";
private DictCacheKey() {
}
/**
* 构建字典类型缓存 Key
*
* @param tenantId 租户 ID
* @param dictType 字典类型
* @return 缓存 Key
*/
public static String dictTypeKey(Long tenantId, String dictType) {
return StrUtil.format(DICT_TYPE_KEY, tenantId, dictType);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
字典缓存服务接口:
package io.github.atengk.common.dict;
import java.util.List;
import java.util.Map;
/**
* 字典缓存服务
*
* @author Ateng
* @since 2026-05-05
*/
public interface DictCacheService {
/**
* 根据字典类型获取字典数据
*
* @param tenantId 租户 ID
* @param dictType 字典类型
* @return 字典数据列表
*/
List<DictItemVO> listDictItems(Long tenantId, String dictType);
/**
* 根据字典类型批量获取字典数据
*
* @param tenantId 租户 ID
* @param dictTypes 字典类型列表
* @return 字典类型映射
*/
Map<String, List<DictItemVO>> mapDictItems(Long tenantId, List<String> dictTypes);
/**
* 清理指定字典类型缓存
*
* @param tenantId 租户 ID
* @param dictType 字典类型
*/
void clearDictType(Long tenantId, String dictType);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
基于本地缓存的简单实现示例。实际生产项目可以替换为 Redis、Caffeine 或二级缓存。
package io.github.atengk.common.dict.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.dict.DictCacheKey;
import io.github.atengk.common.dict.DictCacheService;
import io.github.atengk.common.dict.DictItemVO;
import io.github.atengk.module.system.dict.mapper.DictDataMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 字典缓存服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DictCacheServiceImpl implements DictCacheService {
private final DictDataMapper dictDataMapper;
private final Map<String, List<DictItemVO>> localCache = new ConcurrentHashMap<>();
/**
* 根据字典类型获取字典数据
*
* @param tenantId 租户 ID
* @param dictType 字典类型
* @return 字典数据列表
*/
@Override
public List<DictItemVO> listDictItems(Long tenantId, String dictType) {
if (tenantId == null || StrUtil.isBlank(dictType)) {
return List.of();
}
String cacheKey = DictCacheKey.dictTypeKey(tenantId, dictType);
return localCache.computeIfAbsent(cacheKey, key -> {
List<DictItemVO> records = dictDataMapper.selectEnabledDictItems(tenantId, dictType);
log.debug("加载字典缓存,租户ID:{},字典类型:{},数量:{}",
tenantId, dictType, records.size());
return records;
});
}
/**
* 根据字典类型批量获取字典数据
*
* @param tenantId 租户 ID
* @param dictTypes 字典类型列表
* @return 字典类型映射
*/
@Override
public Map<String, List<DictItemVO>> mapDictItems(Long tenantId, List<String> dictTypes) {
if (tenantId == null || CollUtil.isEmpty(dictTypes)) {
return MapUtil.empty();
}
return dictTypes.stream()
.distinct()
.collect(Collectors.toMap(
Function.identity(),
dictType -> listDictItems(tenantId, dictType),
(oldValue, newValue) -> oldValue
));
}
/**
* 清理指定字典类型缓存
*
* @param tenantId 租户 ID
* @param dictType 字典类型
*/
@Override
public void clearDictType(Long tenantId, String dictType) {
if (tenantId == null || StrUtil.isBlank(dictType)) {
return;
}
String cacheKey = DictCacheKey.dictTypeKey(tenantId, dictType);
localCache.remove(cacheKey);
log.info("清理字典缓存完成,租户ID:{},字典类型:{}", tenantId, dictType);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
字典缓存建议:
| 场景 | 建议 |
|---|---|
| 单体应用 | 本地缓存即可 |
| 多实例部署 | 使用 Redis 或发布缓存刷新事件 |
| 修改字典 | 清理指定 dictType 缓存 |
| 删除字典 | 清理指定 dictType 缓存 |
| 租户字典 | 缓存 Key 必须包含 tenantId |
| 高频字典 | 启动预热或首次懒加载 |
| 字典禁用 | 禁用后必须刷新缓存 |
字典查询
字典查询主要提供前端下拉框、单选框、标签渲染等基础数据。常见接口包括:根据字典类型查询字典项、批量查询多个字典类型、查询全部启用字典类型。
字典项返回对象:
package io.github.atengk.common.dict;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 字典项返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class DictItemVO implements Serializable {
/**
* 字典类型
*/
private String dictType;
/**
* 字典值
*/
private String dictValue;
/**
* 字典标签
*/
private String dictLabel;
/**
* 标签样式
*/
private String tagType;
/**
* 自定义样式
*/
private String cssClass;
/**
* 排序
*/
private Integer sortOrder;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Mapper 查询:
package io.github.atengk.module.system.dict.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.atengk.common.dict.DictItemVO;
import io.github.atengk.module.system.dict.entity.DictDataEntity;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 字典数据 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface DictDataMapper extends BaseMapper<DictDataEntity> {
/**
* 查询启用字典项
*
* @param tenantId 租户 ID
* @param dictType 字典类型
* @return 字典项列表
*/
List<DictItemVO> selectEnabledDictItems(@Param("tenantId") Long tenantId,
@Param("dictType") String dictType);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Mapper XML:
<select id="selectEnabledDictItems" resultType="io.github.atengk.common.dict.DictItemVO">
SELECT
dict_type,
dict_value,
dict_label,
tag_type,
css_class,
sort_order
FROM sys_dict_data
WHERE tenant_id = #{tenantId}
AND dict_type = #{dictType}
AND status = 1
AND deleted = 0
ORDER BY sort_order ASC, id ASC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
Controller 示例:
package io.github.atengk.module.system.dict.controller;
import io.github.atengk.common.dict.DictCacheService;
import io.github.atengk.common.dict.DictItemVO;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.framework.security.CurrentUserContext;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 字典查询接口
*
* @author Ateng
* @since 2026-05-05
*/
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/dicts")
public class DictController {
private final DictCacheService dictCacheService;
private final CurrentUserContext currentUserContext;
/**
* 根据字典类型查询字典项
*
* @param dictType 字典类型
* @return 字典项列表
*/
@GetMapping("/{dictType}")
public ApiResult<List<DictItemVO>> listDictItems(
@PathVariable @NotBlank(message = "字典类型不能为空") String dictType) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
return ApiResult.success(dictCacheService.listDictItems(tenantId, dictType));
}
/**
* 批量查询字典项
*
* @param dictTypes 字典类型列表
* @return 字典项映射
*/
@GetMapping("/batch")
public ApiResult<Map<String, List<DictItemVO>>> mapDictItems(@RequestParam List<String> dictTypes) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
return ApiResult.success(dictCacheService.mapDictItems(tenantId, dictTypes));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
字典查询建议:
| 接口 | 建议 |
|---|---|
| 单类型查询 | /api/v1/dicts/{dictType} |
| 批量查询 | /api/v1/dicts/batch?dictTypes=a,b |
| 启用过滤 | 前端选项只返回启用字典 |
| 管理列表 | 可查询禁用字典 |
| 排序 | 按 sort_order ASC, id ASC |
| 多租户 | 按当前租户查询 |
| 平台字典 | 可设计租户回退逻辑 |
字典注解回显
字典注解回显用于在 VO 字段上标记字典类型,由统一组件自动把编码字段转换为名称字段。例如 VO 中有 status 字段,自动填充 statusName。
定义注解:
package io.github.atengk.common.dict;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* 字典回显注解
*
* @author Ateng
* @since 2026-05-05
*/
@Documented
@Target(FIELD)
@Retention(RUNTIME)
public @interface DictFormat {
/**
* 字典类型
*
* @return 字典类型
*/
String dictType();
/**
* 回显字段名
*
* @return 回显字段名
*/
String targetField() default "";
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
VO 示例:
package io.github.atengk.module.system.user.vo;
import io.github.atengk.common.dict.DictFormat;
import lombok.Getter;
import lombok.Setter;
/**
* 用户分页返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserPageVO {
/**
* 用户 ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 用户状态
*/
@DictFormat(dictType = "user_status", targetField = "statusName")
private Integer status;
/**
* 用户状态名称
*/
private String statusName;
/**
* 性别
*/
@DictFormat(dictType = "gender", targetField = "genderName")
private Integer gender;
/**
* 性别名称
*/
private String genderName;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
字典回显处理器:
package io.github.atengk.common.dict;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.framework.security.CurrentUserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 字典回显处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DictFormatProcessor {
private final DictCacheService dictCacheService;
private final CurrentUserContext currentUserContext;
/**
* 处理单个对象字典回显
*
* @param target 目标对象
*/
public void format(Object target) {
if (ObjectUtil.isNull(target)) {
return;
}
formatList(List.of(target));
}
/**
* 处理集合对象字典回显
*
* @param targets 目标对象列表
*/
public void formatList(List<?> targets) {
if (CollUtil.isEmpty(targets)) {
return;
}
Long tenantId = currentUserContext.getTenantIdOrDefault();
for (Object target : targets) {
Field[] fields = target.getClass().getDeclaredFields();
for (Field field : fields) {
DictFormat dictFormat = field.getAnnotation(DictFormat.class);
if (dictFormat == null) {
continue;
}
Object value = BeanUtil.getProperty(target, field.getName());
if (ObjectUtil.isNull(value)) {
continue;
}
String targetField = StrUtil.blankToDefault(dictFormat.targetField(), field.getName() + "Name");
String label = getDictLabel(tenantId, dictFormat.dictType(), String.valueOf(value));
BeanUtil.setProperty(target, targetField, label);
}
}
}
/**
* 获取字典标签
*
* @param tenantId 租户 ID
* @param dictType 字典类型
* @param dictValue 字典值
* @return 字典标签
*/
private String getDictLabel(Long tenantId, String dictType, String dictValue) {
List<DictItemVO> dictItems = dictCacheService.listDictItems(tenantId, dictType);
if (CollUtil.isEmpty(dictItems)) {
return dictValue;
}
Map<String, DictItemVO> itemMap = dictItems.stream()
.collect(Collectors.toMap(DictItemVO::getDictValue, Function.identity(), (a, b) -> a));
DictItemVO dictItem = itemMap.get(dictValue);
return dictItem == null ? dictValue : dictItem.getDictLabel();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
Service 使用:
public PageResult<UserPageVO> pageUser(UserPageQuery query) {
Page<UserEntity> page = query.toPage();
Page<UserEntity> result = this.lambdaQuery()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.orderByDesc(UserEntity::getCreateTime)
.page(page);
List<UserPageVO> records = userConvert.toPageVOList(result.getRecords());
dictFormatProcessor.formatList(records);
return PageResult.of(result.getCurrent(), result.getSize(), result.getTotal(), records);
}2
3
4
5
6
7
8
9
10
11
12
13
字典注解回显建议:
| 场景 | 建议 |
|---|---|
| 普通列表 VO | 可以使用注解回显 |
| 详情 VO | 可以使用注解回显 |
| 大批量导出 | 建议批量转换,避免逐条逐字段查缓存 |
| 多字段回显 | 注解方式清晰 |
| 复杂回显 | 手写转换更可控 |
| 性能敏感接口 | 使用批量字典转换 |
枚举回显
枚举回显适合稳定、代码内定义的状态,例如订单状态、支付状态、审批状态等。枚举比字典更适合表达业务状态机,因为它可以在代码中封装状态流转规则。
通用枚举接口:
package io.github.atengk.common.enums;
/**
* 通用编码枚举接口
*
* @author Ateng
* @since 2026-05-05
*/
public interface CodeLabelEnum<T> {
/**
* 获取编码
*
* @return 编码
*/
T getCode();
/**
* 获取标签
*
* @return 标签
*/
String getLabel();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
订单状态枚举:
package io.github.atengk.module.order.enums;
import cn.hutool.core.util.ObjectUtil;
import io.github.atengk.common.enums.CodeLabelEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 订单状态枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum OrderStatusEnum implements CodeLabelEnum<Integer> {
/**
* 待支付
*/
WAIT_PAY(10, "待支付"),
/**
* 已支付
*/
PAID(20, "已支付"),
/**
* 已发货
*/
SHIPPED(30, "已发货"),
/**
* 已完成
*/
FINISHED(40, "已完成"),
/**
* 已取消
*/
CANCELED(90, "已取消");
/**
* 状态编码
*/
private final Integer code;
/**
* 状态标签
*/
private final String label;
/**
* 根据编码获取枚举
*
* @param code 状态编码
* @return 订单状态枚举
*/
public static OrderStatusEnum ofCode(Integer code) {
return Arrays.stream(values())
.filter(item -> ObjectUtil.equals(item.getCode(), code))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("订单状态不存在:" + code));
}
/**
* 根据编码获取标签
*
* @param code 状态编码
* @return 状态标签
*/
public static String getLabelByCode(Integer code) {
return Arrays.stream(values())
.filter(item -> ObjectUtil.equals(item.getCode(), code))
.map(OrderStatusEnum::getLabel)
.findFirst()
.orElse("");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
订单 VO:
package io.github.atengk.module.order.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 订单分页返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class OrderPageVO {
/**
* 订单 ID
*/
private Long id;
/**
* 订单号
*/
private String orderNo;
/**
* 订单状态
*/
private Integer orderStatus;
/**
* 订单状态名称
*/
private String orderStatusName;
/**
* 支付金额
*/
private BigDecimal payAmount;
/**
* 创建时间
*/
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
手动回显:
private void fillOrderStatusName(OrderPageVO vo) {
vo.setOrderStatusName(OrderStatusEnum.getLabelByCode(vo.getOrderStatus()));
}2
3
枚举回显建议:
| 场景 | 建议 |
|---|---|
| 订单状态 | 枚举 |
| 支付状态 | 枚举 |
| 审批状态 | 枚举 |
| 是否启用 | 枚举或字典均可 |
| 性别 | 字典或枚举均可 |
| 商品分类 | 不用枚举,用业务表 |
| 运营可维护选项 | 不用枚举,用字典 |
| 状态流转 | 优先枚举封装规则 |
VO 字段转换
VO 字段转换用于把 Entity 中的编码字段转换成前端展示字段。推荐在 MapStruct 转换器中处理枚举回显,字典回显可以在转换后由字典处理器统一处理。
MapStruct 示例:
package io.github.atengk.module.order.convert;
import io.github.atengk.module.order.entity.OrderEntity;
import io.github.atengk.module.order.enums.OrderStatusEnum;
import io.github.atengk.module.order.vo.OrderPageVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.util.List;
/**
* 订单对象转换器
*
* @author Ateng
* @since 2026-05-05
*/
@Mapper(componentModel = "spring")
public interface OrderConvert {
/**
* 实体转分页返回对象
*
* @param entity 订单实体
* @return 订单分页返回对象
*/
@Mapping(target = "orderStatusName", expression = "java(toOrderStatusName(entity.getOrderStatus()))")
OrderPageVO toPageVO(OrderEntity entity);
/**
* 实体列表转分页返回对象列表
*
* @param entities 订单实体列表
* @return 订单分页返回对象列表
*/
List<OrderPageVO> toPageVOList(List<OrderEntity> entities);
/**
* 获取订单状态名称
*
* @param orderStatus 订单状态
* @return 订单状态名称
*/
default String toOrderStatusName(Integer orderStatus) {
return OrderStatusEnum.getLabelByCode(orderStatus);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
使用 Hutool BeanUtil 转换时,可以手动补充字段:
public OrderPageVO toOrderPageVO(OrderEntity entity) {
OrderPageVO vo = BeanUtil.copyProperties(entity, OrderPageVO.class);
vo.setOrderStatusName(OrderStatusEnum.getLabelByCode(entity.getOrderStatus()));
return vo;
}2
3
4
5
VO 字段转换建议:
| 场景 | 建议 |
|---|---|
| 枚举字段 | MapStruct 转换时填充 |
| 字典字段 | 注解或批量转换 |
| 用户名称 | 批量查询用户 Map 后填充 |
| 部门名称 | 批量查询或缓存后填充 |
| 金额字段 | 不建议在 VO 中重新计算核心金额 |
| 时间字段 | 保持 LocalDateTime 或统一格式化 |
| 敏感字段 | VO 层脱敏 |
不要在 VO 的 getter 中查询数据库或字典。getter 应保持纯字段返回,不应引入数据库访问。
批量字典转换
批量字典转换用于处理列表或分页结果,避免每条记录、每个字段都单独查询字典。正确做法是一次性收集需要的字典类型,批量加载字典项,然后在内存中完成回显。
批量转换器:
package io.github.atengk.common.dict;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import io.github.atengk.framework.security.CurrentUserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 批量字典转换器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
@RequiredArgsConstructor
public class BatchDictConverter {
private final DictCacheService dictCacheService;
private final CurrentUserContext currentUserContext;
/**
* 批量处理字典回显
*
* @param records 数据列表
*/
public void convert(List<?> records) {
if (CollUtil.isEmpty(records)) {
return;
}
Class<?> recordClass = records.get(0).getClass();
List<Field> dictFields = Arrays.stream(recordClass.getDeclaredFields())
.filter(field -> field.isAnnotationPresent(DictFormat.class))
.toList();
if (CollUtil.isEmpty(dictFields)) {
return;
}
Long tenantId = currentUserContext.getTenantIdOrDefault();
List<String> dictTypes = dictFields.stream()
.map(field -> field.getAnnotation(DictFormat.class).dictType())
.distinct()
.toList();
Map<String, List<DictItemVO>> dictMap = dictCacheService.mapDictItems(tenantId, dictTypes);
Map<String, Map<String, DictItemVO>> dictItemMap = buildDictItemMap(dictMap);
for (Object record : records) {
fillRecord(record, dictFields, dictItemMap);
}
}
/**
* 构建字典项映射
*
* @param dictMap 字典映射
* @return 字典项映射
*/
private Map<String, Map<String, DictItemVO>> buildDictItemMap(Map<String, List<DictItemVO>> dictMap) {
if (dictMap == null || dictMap.isEmpty()) {
return Map.of();
}
Map<String, Map<String, DictItemVO>> result = new HashMap<>();
dictMap.forEach((dictType, items) -> {
Map<String, DictItemVO> itemMap = CollUtil.emptyIfNull(items)
.stream()
.collect(Collectors.toMap(DictItemVO::getDictValue, Function.identity(), (a, b) -> a));
result.put(dictType, itemMap);
});
return result;
}
/**
* 填充单条记录
*
* @param record 记录对象
* @param dictFields 字典字段
* @param dictItemMap 字典项映射
*/
private void fillRecord(Object record,
List<Field> dictFields,
Map<String, Map<String, DictItemVO>> dictItemMap) {
for (Field field : dictFields) {
DictFormat dictFormat = field.getAnnotation(DictFormat.class);
Object value = BeanUtil.getProperty(record, field.getName());
if (ObjectUtil.isNull(value)) {
continue;
}
Map<String, DictItemVO> itemMap = dictItemMap.getOrDefault(dictFormat.dictType(), Map.of());
DictItemVO item = itemMap.get(String.valueOf(value));
if (item == null) {
continue;
}
String targetField = cn.hutool.core.util.StrUtil.blankToDefault(
dictFormat.targetField(),
field.getName() + "Name"
);
BeanUtil.setProperty(record, targetField, item.getDictLabel());
String tagField = field.getName() + "TagType";
if (BeanUtil.hasSetter(record.getClass(), tagField)) {
BeanUtil.setProperty(record, tagField, item.getTagType());
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
VO 增加标签样式字段:
package io.github.atengk.module.system.user.vo;
import io.github.atengk.common.dict.DictFormat;
import lombok.Getter;
import lombok.Setter;
/**
* 用户分页返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserPageVO {
/**
* 用户 ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 状态
*/
@DictFormat(dictType = "user_status", targetField = "statusName")
private Integer status;
/**
* 状态名称
*/
private String statusName;
/**
* 状态标签样式
*/
private String statusTagType;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
分页接口中使用:
public PageResult<UserPageVO> pageUser(UserPageQuery query) {
Page<UserEntity> page = query.toPage();
Page<UserEntity> result = this.lambdaQuery()
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.orderByDesc(UserEntity::getCreateTime)
.page(page);
List<UserPageVO> records = userConvert.toPageVOList(result.getRecords());
batchDictConverter.convert(records);
return PageResult.of(result.getCurrent(), result.getSize(), result.getTotal(), records);
}2
3
4
5
6
7
8
9
10
11
12
13
批量字典转换建议:
| 场景 | 建议 |
|---|---|
| 分页列表 | 使用批量转换 |
| 导出数据 | 使用批量转换 |
| 单条详情 | 可以单条转换 |
| 字典类型较多 | 先收集类型后批量加载 |
| 字典项较少 | 缓存后内存匹配 |
| 大数据导出 | 分批查询、分批转换 |
| 前端已缓存字典 | 后端仍建议返回必要名称,降低前端耦合 |
国际化字典扩展
国际化字典用于支持不同语言环境下展示不同标签。例如 status=1 在中文环境展示“启用”,英文环境展示“Enabled”。国际化可以通过扩展字典数据表,也可以通过独立字典国际化表实现。
方案一:字典数据表增加语言字段。适合字典数据规模不大、每种语言都作为独立字典项维护的场景。
CREATE TABLE sys_dict_data_i18n (
id BIGINT NOT NULL COMMENT '主键ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
dict_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '字典类型',
dict_value VARCHAR(64) NOT NULL DEFAULT '' COMMENT '字典值',
locale VARCHAR(32) NOT NULL DEFAULT '' COMMENT '语言标识,例如 zh-CN、en-US',
dict_label VARCHAR(128) NOT NULL DEFAULT '' COMMENT '字典标签',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_type_value_locale (tenant_id, dict_type, dict_value, locale),
KEY idx_tenant_locale (tenant_id, locale)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字典国际化表';2
3
4
5
6
7
8
9
10
11
12
13
14
国际化字典 VO:
package io.github.atengk.common.dict;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 国际化字典项返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class I18nDictItemVO implements Serializable {
/**
* 字典类型
*/
private String dictType;
/**
* 字典值
*/
private String dictValue;
/**
* 语言标识
*/
private String locale;
/**
* 字典标签
*/
private String dictLabel;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
语言环境上下文:
package io.github.atengk.common.i18n;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
/**
* 语言环境上下文
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
public final class LocaleContext {
private static final ThreadLocal<String> LOCALE_HOLDER = new ThreadLocal<>();
private static final String DEFAULT_LOCALE = "zh-CN";
private LocaleContext() {
}
/**
* 设置语言环境
*
* @param locale 语言环境
*/
public static void setLocale(String locale) {
LOCALE_HOLDER.set(StrUtil.blankToDefault(locale, DEFAULT_LOCALE));
}
/**
* 获取语言环境
*
* @return 语言环境
*/
public static String getLocale() {
return StrUtil.blankToDefault(LOCALE_HOLDER.get(), DEFAULT_LOCALE);
}
/**
* 清理语言环境
*/
public static void clear() {
LOCALE_HOLDER.remove();
log.trace("语言环境上下文已清理");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
国际化字典查询 Mapper:
package io.github.atengk.module.system.dict.mapper;
import io.github.atengk.common.dict.I18nDictItemVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 字典国际化 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface DictI18nMapper {
/**
* 查询国际化字典项
*
* @param tenantId 租户 ID
* @param dictType 字典类型
* @param locale 语言环境
* @return 国际化字典项列表
*/
List<I18nDictItemVO> selectI18nDictItems(@Param("tenantId") Long tenantId,
@Param("dictType") String dictType,
@Param("locale") String locale);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Mapper XML:
<select id="selectI18nDictItems" resultType="io.github.atengk.common.dict.I18nDictItemVO">
SELECT
dict_type,
dict_value,
locale,
dict_label
FROM sys_dict_data_i18n
WHERE tenant_id = #{tenantId}
AND dict_type = #{dictType}
AND locale = #{locale}
AND deleted = 0
</select>2
3
4
5
6
7
8
9
10
11
12
国际化回显策略:
| 场景 | 建议 |
|---|---|
| 默认语言 | 使用 zh-CN |
| 请求语言 | 从 Accept-Language 或 Header 获取 |
| 缓存 Key | 加入 locale |
| 找不到翻译 | 回退默认语言 |
| 默认语言仍找不到 | 回退 dict_value |
| 枚举国际化 | 可通过消息资源或枚举扩展实现 |
| 前端国际化 | 后端返回 code,前端基于 i18n 资源展示也可行 |
国际化字典缓存 Key 示例:
package io.github.atengk.common.dict;
import cn.hutool.core.util.StrUtil;
/**
* 国际化字典缓存 Key 工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class I18nDictCacheKey {
private static final String I18N_DICT_TYPE_KEY = "dict:i18n:{}:{}:{}";
private I18nDictCacheKey() {
}
/**
* 构建国际化字典缓存 Key
*
* @param tenantId 租户 ID
* @param dictType 字典类型
* @param locale 语言环境
* @return 缓存 Key
*/
public static String dictTypeKey(Long tenantId, String dictType, String locale) {
return StrUtil.format(I18N_DICT_TYPE_KEY, tenantId, dictType, locale);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
字典与枚举回显设计建议
字典和枚举不要混用得过于随意。推荐按以下标准选择:
| 类型 | 推荐方案 |
|---|---|
| 订单状态 | 枚举 |
| 支付状态 | 枚举 |
| 审批状态 | 枚举 |
| 用户状态 | 枚举或字典 |
| 性别 | 字典或枚举 |
| 通知类型 | 字典 |
| 系统参数选项 | 字典 |
| 商品分类 | 业务表 |
| 地区数据 | 业务表或标准区域表 |
| 运营标签 | 字典或业务表 |
| 国际化展示 | 字典国际化表或前端 i18n |
落地规范如下:
| 规范项 | 建议 |
|---|---|
| 入库值 | 只存稳定编码 |
| 接口入参 | 使用编码,不使用名称 |
| 接口出参 | 返回编码和名称 |
| 列表回显 | 批量转换 |
| 详情回显 | 单条转换或批量转换均可 |
| 导出回显 | 使用标签名称 |
| 字典缓存 | 按 tenantId + dictType 缓存 |
| 字典修改 | 修改后刷新缓存 |
| 枚举状态 | 不允许随意修改历史编码含义 |
| 国际化 | 缓存 Key 加 locale |
字典适合运行期维护,枚举适合编译期固定。状态机、业务流转、核心交易状态优先使用枚举;后台可配置、展示型、低风险选项优先使用字典。
批量导入导出
批量导入导出通常用于用户、商品、订单、库存、字典、客户、合同等数据的批处理。导入导出的重点不是“读写 Excel”本身,而是数据校验、重复处理、事务边界、失败明细、大数据量性能、权限控制和异步任务追踪。
Spring Boot 3 项目可以直接使用 com.alibaba:easyexcel,Maven Central 当前可见 4.0.3 版本,实际项目建议用 ${easyexcel.version} 统一管理版本,避免代码中写死版本号。(Maven Repository)
Excel 导入
Excel 导入流程一般分为上传文件、解析 Excel、逐行校验、转换实体、批量入库、记录失败明细。不要在 Controller 中直接写复杂导入逻辑,Controller 只接收文件并调用 Service。
导入用户 Excel 行对象如下。
package io.github.atengk.module.system.user.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Getter;
import lombok.Setter;
/**
* 用户导入 Excel 行对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserImportExcel {
/**
* 用户名
*/
@ExcelProperty("用户名")
private String username;
/**
* 昵称
*/
@ExcelProperty("昵称")
private String nickname;
/**
* 手机号
*/
@ExcelProperty("手机号")
private String phone;
/**
* 邮箱
*/
@ExcelProperty("邮箱")
private String email;
/**
* 部门编码
*/
@ExcelProperty("部门编码")
private String deptCode;
/**
* 状态:启用、禁用
*/
@ExcelProperty("状态")
private String statusName;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Controller 接收 Excel 文件并调用导入服务。
package io.github.atengk.module.system.user.controller;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.module.system.user.service.UserImportService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* 用户导入接口
*
* @author Ateng
* @since 2026-05-05
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users/import")
public class UserImportController {
private final UserImportService userImportService;
/**
* 导入用户
*
* @param file Excel 文件
* @return 导入结果
*/
@PostMapping
public ApiResult<Long> importUsers(@RequestPart("file") MultipartFile file) {
Long taskId = userImportService.importUsers(file);
return ApiResult.success(taskId);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
导入服务基础接口。
package io.github.atengk.module.system.user.service;
import org.springframework.web.multipart.MultipartFile;
/**
* 用户导入服务
*
* @author Ateng
* @since 2026-05-05
*/
public interface UserImportService {
/**
* 导入用户
*
* @param file Excel 文件
* @return 导入任务 ID
*/
Long importUsers(MultipartFile file);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
导入文件建议先校验文件类型和大小,再解析数据。文件格式不能只依赖扩展名,还应结合 Content-Type 和业务白名单。
package io.github.atengk.common.excel;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.exception.BusinessException;
import org.springframework.web.multipart.MultipartFile;
/**
* Excel 文件校验工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class ExcelFileValidator {
private static final long MAX_FILE_SIZE = 20L * 1024 * 1024;
private ExcelFileValidator() {
}
/**
* 校验导入文件
*
* @param file 上传文件
*/
public static void validateImportFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException("导入文件不能为空");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new BusinessException("导入文件不能超过20MB");
}
String filename = file.getOriginalFilename();
if (StrUtil.isBlank(filename)) {
throw new BusinessException("文件名不能为空");
}
String lowerName = filename.toLowerCase();
if (!StrUtil.endWithAny(lowerName, ".xlsx", ".xls")) {
throw new BusinessException("只支持 xlsx 或 xls 格式文件");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
导入设计建议:
| 场景 | 建议 |
|---|---|
| 文件大小 | 限制最大上传大小 |
| 文件格式 | 只允许 .xlsx、.xls |
| 表头校验 | 必须校验模板是否正确 |
| 行数限制 | 同步导入限制行数,大数据量走异步 |
| 字段校验 | 每行校验,失败记录行号和原因 |
| 重复数据 | 明确覆盖、跳过、失败策略 |
| 事务 | 小批量可整体事务,大批量建议分批事务 |
| 操作日志 | 记录导入人、文件名、成功数、失败数 |
Excel 导出
Excel 导出通常分为同步导出和异步导出。小数据量可以同步导出;大数据量必须异步导出,避免 HTTP 请求超时、内存过高、数据库长时间占用连接。
导出用户 Excel 对象如下。
package io.github.atengk.module.system.user.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户导出 Excel 行对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserExportExcel {
/**
* 用户名
*/
@ExcelProperty("用户名")
private String username;
/**
* 昵称
*/
@ExcelProperty("昵称")
private String nickname;
/**
* 手机号
*/
@ExcelProperty("手机号")
private String phone;
/**
* 邮箱
*/
@ExcelProperty("邮箱")
private String email;
/**
* 部门名称
*/
@ExcelProperty("部门名称")
private String deptName;
/**
* 状态
*/
@ExcelProperty("状态")
private String statusName;
/**
* 创建时间
*/
@ExcelProperty("创建时间")
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
同步导出接口如下。
package io.github.atengk.module.system.user.controller;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.service.UserExportService;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 用户导出接口
*
* @author Ateng
* @since 2026-05-05
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users/export")
public class UserExportController {
private final UserExportService userExportService;
/**
* 导出用户
*
* @param query 查询参数
* @param response 响应对象
*/
@GetMapping
public void exportUsers(@Valid UserPageQuery query, HttpServletResponse response) {
userExportService.exportUsers(query, response);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
导出响应头工具如下。
package io.github.atengk.common.excel;
import cn.hutool.core.net.URLEncodeUtil;
import jakarta.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
/**
* Excel 响应工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class ExcelResponseUtil {
private ExcelResponseUtil() {
}
/**
* 设置 Excel 下载响应头
*
* @param response 响应对象
* @param filename 文件名
*/
public static void setDownloadHeader(HttpServletResponse response, String filename) {
String encodedFilename = URLEncodeUtil.encode(filename, StandardCharsets.UTF_8);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + encodedFilename);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
导出建议:
| 场景 | 建议 |
|---|---|
| 小数据量 | 同步导出 |
| 大数据量 | 异步导出 |
| 导出字段 | 使用专用 Excel VO,不直接导出 Entity |
| 敏感字段 | 默认脱敏 |
| 字典字段 | 导出名称,不导出编码 |
| 数据权限 | 导出必须受数据权限控制 |
| 查询范围 | 必须限制时间范围或最大导出数量 |
| 操作日志 | 导出属于敏感操作,必须记录 |
EasyExcel 集成
引入 EasyExcel 依赖。
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>${easyexcel.version}</version>
</dependency>2
3
4
5
导入实现可以使用监听器分批处理,避免一次性把所有行加载到内存。
package io.github.atengk.module.system.user.excel;
import cn.hutool.core.collection.CollUtil;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.read.listener.ReadListener;
import io.github.atengk.module.system.user.service.UserImportRowService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
/**
* 用户导入监听器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@RequiredArgsConstructor
public class UserImportListener implements ReadListener<UserImportExcel> {
private static final int BATCH_SIZE = 500;
private final UserImportRowService userImportRowService;
private final Long taskId;
private final List<UserImportExcel> cachedRows = new ArrayList<>();
/**
* 读取每一行数据
*
* @param row 行数据
* @param context 解析上下文
*/
@Override
public void invoke(UserImportExcel row, AnalysisContext context) {
cachedRows.add(row);
if (cachedRows.size() >= BATCH_SIZE) {
saveBatch();
}
}
/**
* 所有数据读取完成
*
* @param context 解析上下文
*/
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
saveBatch();
log.info("用户导入 Excel 解析完成,任务ID:{}", taskId);
}
/**
* 分批保存数据
*/
private void saveBatch() {
if (CollUtil.isEmpty(cachedRows)) {
return;
}
userImportRowService.handleRows(taskId, new ArrayList<>(cachedRows));
cachedRows.clear();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
导入 Service 实现。
package io.github.atengk.module.system.user.service.impl;
import com.alibaba.excel.EasyExcel;
import io.github.atengk.common.excel.ExcelFileValidator;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.module.system.user.excel.UserImportExcel;
import io.github.atengk.module.system.user.excel.UserImportListener;
import io.github.atengk.module.system.user.service.UserImportRowService;
import io.github.atengk.module.system.user.service.UserImportService;
import io.github.atengk.module.system.user.service.UserImportTaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* 用户导入服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserImportServiceImpl implements UserImportService {
private final UserImportTaskService userImportTaskService;
private final UserImportRowService userImportRowService;
/**
* 导入用户
*
* @param file Excel 文件
* @return 导入任务 ID
*/
@Override
public Long importUsers(MultipartFile file) {
ExcelFileValidator.validateImportFile(file);
Long taskId = userImportTaskService.createImportTask(file.getOriginalFilename());
try {
EasyExcel.read(file.getInputStream(), UserImportExcel.class,
new UserImportListener(userImportRowService, taskId))
.sheet()
.doRead();
userImportTaskService.finishTask(taskId);
log.info("用户导入完成,任务ID:{}", taskId);
return taskId;
} catch (Exception exception) {
userImportTaskService.failTask(taskId, exception.getMessage());
log.error("用户导入失败,任务ID:{}", taskId, exception);
throw new BusinessException("用户导入失败,请查看失败明细");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
同步导出实现。
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.alibaba.excel.EasyExcel;
import io.github.atengk.common.excel.ExcelResponseUtil;
import io.github.atengk.module.system.user.excel.UserExportExcel;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.service.UserExportService;
import io.github.atengk.module.system.user.service.UserQueryService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 用户导出服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserExportServiceImpl implements UserExportService {
private final UserQueryService userQueryService;
/**
* 导出用户
*
* @param query 查询参数
* @param response 响应对象
*/
@Override
public void exportUsers(UserPageQuery query, HttpServletResponse response) {
List<UserExportExcel> records = userQueryService.listUserExportRecords(query);
if (CollUtil.isEmpty(records)) {
records = List.of();
}
ExcelResponseUtil.setDownloadHeader(response, "用户列表.xlsx");
EasyExcel.write(response.getOutputStream(), UserExportExcel.class)
.sheet("用户列表")
.doWrite(records);
log.info("用户导出完成,数量:{}", records.size());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
EasyExcel 集成建议:
| 场景 | 建议 |
|---|---|
| 导入 | 使用 Listener 分批处理 |
| 导出 | 小数据量直接 doWrite |
| 大数据量导出 | 分页查询 + 分批写入 |
| 模板表头 | 使用 @ExcelProperty |
| 日期格式 | 可使用转换器统一格式 |
| 字典字段 | 导入时名称转编码,导出时编码转名称 |
| 异常处理 | 捕获后记录任务失败原因 |
| 内存控制 | 不要一次性读取超大 Excel |
导入参数校验
导入参数校验分为文件级校验、模板级校验、行级校验、业务级校验。文件级校验检查文件大小和格式;模板级校验检查表头是否匹配;行级校验检查必填、格式、长度;业务级校验检查唯一性、部门是否存在、状态是否合法等。
导入失败明细对象如下。
package io.github.atengk.common.excel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Excel 导入失败明细
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ImportFailDetail {
/**
* 行号
*/
private Integer rowIndex;
/**
* 字段名
*/
private String fieldName;
/**
* 原始值
*/
private String originalValue;
/**
* 失败原因
*/
private String failReason;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
用户导入行校验器如下。
package io.github.atengk.module.system.user.excel;
import cn.hutool.core.util.ReUtil;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.excel.ImportFailDetail;
import java.util.ArrayList;
import java.util.List;
/**
* 用户导入行校验器
*
* @author Ateng
* @since 2026-05-05
*/
public final class UserImportRowValidator {
private UserImportRowValidator() {
}
/**
* 校验用户导入行
*
* @param rowIndex 行号
* @param row 行数据
* @return 失败明细列表
*/
public static List<ImportFailDetail> validate(Integer rowIndex, UserImportExcel row) {
List<ImportFailDetail> failDetails = new ArrayList<>();
if (StrUtil.isBlank(row.getUsername())) {
failDetails.add(new ImportFailDetail(rowIndex, "用户名", row.getUsername(), "用户名不能为空"));
}
if (StrUtil.isBlank(row.getNickname())) {
failDetails.add(new ImportFailDetail(rowIndex, "昵称", row.getNickname(), "昵称不能为空"));
}
if (StrUtil.isBlank(row.getPhone())) {
failDetails.add(new ImportFailDetail(rowIndex, "手机号", row.getPhone(), "手机号不能为空"));
} else if (!ReUtil.isMatch("^1[3-9]\\d{9}$", row.getPhone())) {
failDetails.add(new ImportFailDetail(rowIndex, "手机号", row.getPhone(), "手机号格式不正确"));
}
if (StrUtil.isNotBlank(row.getEmail())
&& !ReUtil.isMatch("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$", row.getEmail())) {
failDetails.add(new ImportFailDetail(rowIndex, "邮箱", row.getEmail(), "邮箱格式不正确"));
}
if (StrUtil.isBlank(row.getDeptCode())) {
failDetails.add(new ImportFailDetail(rowIndex, "部门编码", row.getDeptCode(), "部门编码不能为空"));
}
if (StrUtil.isBlank(row.getStatusName())) {
failDetails.add(new ImportFailDetail(rowIndex, "状态", row.getStatusName(), "状态不能为空"));
}
return failDetails;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
导入参数校验建议:
| 校验类型 | 说明 |
|---|---|
| 文件校验 | 文件大小、扩展名、是否为空 |
| 模板校验 | 表头是否符合模板 |
| 基础校验 | 必填、长度、手机号、邮箱 |
| 字典校验 | 状态名称是否存在 |
| 关联校验 | 部门编码是否存在 |
| 唯一性校验 | 用户名、手机号是否重复 |
| 权限校验 | 是否允许导入到目标部门 |
| 行数校验 | 防止一次导入过多数据 |
导入重复数据处理
重复数据处理要明确策略。常见策略包括:导入文件内重复直接失败、数据库已存在则跳过、数据库已存在则覆盖、数据库已存在则报错。
推荐在接口或任务中明确导入模式。
package io.github.atengk.common.excel;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 导入重复数据处理模式
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum ImportDuplicateModeEnum {
/**
* 已存在时报错
*/
FAIL("fail", "已存在时报错"),
/**
* 已存在时跳过
*/
SKIP("skip", "已存在时跳过"),
/**
* 已存在时覆盖
*/
COVER("cover", "已存在时覆盖");
/**
* 模式编码
*/
private final String code;
/**
* 模式名称
*/
private final String name;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
处理导入行时,先检查 Excel 内部重复,再检查数据库重复。
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.excel.ImportFailDetail;
import io.github.atengk.module.system.user.excel.UserImportExcel;
import io.github.atengk.module.system.user.excel.UserImportRowValidator;
import io.github.atengk.module.system.user.service.UserImportFailService;
import io.github.atengk.module.system.user.service.UserImportRowService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* 用户导入行处理服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserImportRowServiceImpl implements UserImportRowService {
private final UserImportFailService userImportFailService;
/**
* 处理导入行
*
* @param taskId 导入任务 ID
* @param rows Excel 行数据
*/
@Override
public void handleRows(Long taskId, List<UserImportExcel> rows) {
if (CollUtil.isEmpty(rows)) {
return;
}
List<ImportFailDetail> failDetails = new ArrayList<>();
Map<String, Long> usernameCountMap = rows.stream()
.filter(row -> StrUtil.isNotBlank(row.getUsername()))
.collect(Collectors.groupingBy(UserImportExcel::getUsername, Collectors.counting()));
for (int index = 0; index < rows.size(); index++) {
UserImportExcel row = rows.get(index);
int rowIndex = index + 2;
failDetails.addAll(UserImportRowValidator.validate(rowIndex, row));
if (StrUtil.isNotBlank(row.getUsername()) && usernameCountMap.getOrDefault(row.getUsername(), 0L) > 1) {
failDetails.add(new ImportFailDetail(rowIndex, "用户名", row.getUsername(), "导入文件内用户名重复"));
}
}
if (CollUtil.isNotEmpty(failDetails)) {
userImportFailService.saveFailDetails(taskId, failDetails);
log.warn("用户导入批次存在失败数据,任务ID:{},失败数量:{}", taskId, failDetails.size());
return;
}
// 校验通过后继续做数据库唯一性校验和批量入库
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
重复数据处理建议:
| 策略 | 适用场景 |
|---|---|
| 报错 | 用户、角色、部门等主数据 |
| 跳过 | 非关键数据批量补录 |
| 覆盖 | 明确以 Excel 为准的数据同步 |
| 合并 | 复杂业务,需要单独规则 |
| 文件内重复 | 一般直接失败 |
| 数据库重复 | 根据导入模式处理 |
| 幂等导入 | 使用业务唯一键控制 |
导入事务控制
导入事务控制要根据数据量和业务要求选择。小数据量可以一个事务完成,保证全部成功或全部失败;大数据量建议分批事务,允许部分成功,并记录失败明细。
分批事务服务如下。
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 用户导入事务服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class UserImportTransactionService extends ServiceImpl<UserMapper, UserEntity> {
/**
* 使用独立事务保存一批用户
*
* @param users 用户列表
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void saveUsersInNewTransaction(List<UserEntity> users) {
if (CollUtil.isEmpty(users)) {
return;
}
this.saveBatch(users, 1000);
log.info("导入用户批次保存成功,数量:{}", users.size());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
导入事务建议:
| 场景 | 建议 |
|---|---|
| 1000 行以内 | 可使用整体事务 |
| 几千行以上 | 分批事务 |
| 必须全部成功 | 整体事务,但限制行数 |
| 允许部分成功 | 分批事务 + 失败明细 |
| 复杂关联写入 | 每批内部保证事务一致 |
| 大文件导入 | 异步任务 + 分批事务 |
| 异常处理 | 失败批次记录明细,不吞异常 |
导入失败明细
失败明细应记录任务 ID、行号、字段、原始值、失败原因。前端可以根据任务 ID 查询失败明细,也可以下载失败明细 Excel。
失败明细表:
CREATE TABLE sys_import_fail_detail (
id BIGINT NOT NULL COMMENT '主键ID',
task_id BIGINT NOT NULL DEFAULT 0 COMMENT '导入任务ID',
row_index INT NOT NULL DEFAULT 0 COMMENT '行号',
field_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '字段名',
original_value VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '原始值',
fail_reason VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '失败原因',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_task_id (task_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='导入失败明细表';2
3
4
5
6
7
8
9
10
11
失败明细实体:
package io.github.atengk.module.system.imports.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import io.github.atengk.common.entity.BaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 导入失败明细实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_import_fail_detail")
public class ImportFailDetailEntity extends BaseEntity {
/**
* 导入任务 ID
*/
private Long taskId;
/**
* 行号
*/
private Integer rowIndex;
/**
* 字段名
*/
private String fieldName;
/**
* 原始值
*/
private String originalValue;
/**
* 失败原因
*/
private String failReason;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
失败明细保存服务。
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.excel.ImportFailDetail;
import io.github.atengk.module.system.imports.entity.ImportFailDetailEntity;
import io.github.atengk.module.system.imports.service.ImportFailDetailService;
import io.github.atengk.module.system.user.service.UserImportFailService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 用户导入失败明细服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Service
@RequiredArgsConstructor
public class UserImportFailServiceImpl implements UserImportFailService {
private final ImportFailDetailService importFailDetailService;
/**
* 保存失败明细
*
* @param taskId 导入任务 ID
* @param failDetails 失败明细
*/
@Override
public void saveFailDetails(Long taskId, List<ImportFailDetail> failDetails) {
if (CollUtil.isEmpty(failDetails)) {
return;
}
List<ImportFailDetailEntity> entities = failDetails.stream()
.map(detail -> {
ImportFailDetailEntity entity = new ImportFailDetailEntity();
entity.setTaskId(taskId);
entity.setRowIndex(detail.getRowIndex());
entity.setFieldName(StrUtil.blankToDefault(detail.getFieldName(), ""));
entity.setOriginalValue(StrUtil.sub(StrUtil.blankToDefault(detail.getOriginalValue(), ""), 0, 1000));
entity.setFailReason(StrUtil.sub(StrUtil.blankToDefault(detail.getFailReason(), ""), 0, 1000));
return entity;
})
.toList();
importFailDetailService.saveBatch(entities, 1000);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
失败明细建议:
| 字段 | 建议 |
|---|---|
task_id | 必须记录,便于查询 |
row_index | Excel 行号,表头通常从第 1 行开始 |
field_name | 失败字段 |
original_value | 原始值,注意截断 |
fail_reason | 明确可读 |
| 下载失败文件 | 可基于失败明细生成 Excel |
| 敏感数据 | 不记录完整身份证、银行卡、密码 |
大数据量导出
大数据量导出不能一次性 list() 全量数据,否则容易造成内存溢出、数据库连接长时间占用、HTTP 超时。推荐使用分页批量查询、分批写入 Excel。
分批写入示例。
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.github.atengk.module.system.user.excel.UserExportExcel;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.service.UserLargeExportService;
import io.github.atengk.module.system.user.service.UserQueryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.OutputStream;
import java.util.List;
/**
* 用户大数据量导出服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserLargeExportServiceImpl implements UserLargeExportService {
private static final long EXPORT_BATCH_SIZE = 2000L;
private final UserQueryService userQueryService;
/**
* 分批导出用户
*
* @param query 查询参数
* @param outputStream 输出流
*/
@Override
public void exportUsersByBatch(UserPageQuery query, OutputStream outputStream) {
try (ExcelWriter excelWriter = EasyExcel.write(outputStream, UserExportExcel.class).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet("用户列表").build();
long pageNum = 1L;
while (true) {
Page<UserExportExcel> page = Page.of(pageNum, EXPORT_BATCH_SIZE);
page.setSearchCount(false);
List<UserExportExcel> records = userQueryService.pageUserExportRecords(page, query);
if (CollUtil.isEmpty(records)) {
break;
}
excelWriter.write(records, writeSheet);
log.info("用户导出写入批次完成,页码:{},数量:{}", pageNum, records.size());
if (records.size() < EXPORT_BATCH_SIZE) {
break;
}
pageNum++;
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
大数据量导出建议:
| 场景 | 建议 |
|---|---|
| 1 万行以内 | 可同步导出 |
| 1 万行以上 | 异步导出 |
| 10 万行以上 | 分批查询、分批写入 |
| 超大报表 | 生成 CSV 或拆分多个 Sheet |
| 查询条件 | 必须限制时间范围 |
| COUNT | 导出一般不需要 COUNT |
| 排序 | 使用索引字段排序 |
| 权限 | 导出必须受权限控制 |
异步导出
异步导出用于处理大数据量导出。用户提交导出任务后立即返回任务 ID,后台线程生成文件,完成后用户查询任务状态并下载文件。
异步导出 Controller:
package io.github.atengk.module.system.user.controller;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.service.UserAsyncExportService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 用户异步导出接口
*
* @author Ateng
* @since 2026-05-05
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users/export-tasks")
public class UserAsyncExportController {
private final UserAsyncExportService userAsyncExportService;
/**
* 创建用户导出任务
*
* @param query 查询参数
* @return 导出任务 ID
*/
@PostMapping
public ApiResult<Long> createExportTask(@RequestBody @Valid UserPageQuery query) {
Long taskId = userAsyncExportService.createUserExportTask(query);
return ApiResult.success(taskId);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
异步导出服务。
package io.github.atengk.module.system.user.service.impl;
import cn.hutool.json.JSONUtil;
import io.github.atengk.module.system.export.entity.ExportTaskEntity;
import io.github.atengk.module.system.export.enums.ExportTaskStatusEnum;
import io.github.atengk.module.system.export.service.ExportTaskService;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.service.UserAsyncExportExecutor;
import io.github.atengk.module.system.user.service.UserAsyncExportService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* 用户异步导出服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserAsyncExportServiceImpl implements UserAsyncExportService {
private final ExportTaskService exportTaskService;
private final UserAsyncExportExecutor userAsyncExportExecutor;
/**
* 创建用户导出任务
*
* @param query 查询参数
* @return 导出任务 ID
*/
@Override
public Long createUserExportTask(UserPageQuery query) {
ExportTaskEntity task = new ExportTaskEntity();
task.setTaskName("用户导出");
task.setTaskType("USER_EXPORT");
task.setTaskStatus(ExportTaskStatusEnum.WAITING.getCode());
task.setQueryParam(JSONUtil.toJsonStr(query));
exportTaskService.save(task);
userAsyncExportExecutor.executeUserExport(task.getId(), query);
log.info("创建用户异步导出任务成功,任务ID:{}", task.getId());
return task.getId();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
异步执行器。
package io.github.atengk.module.system.user.service.impl;
import io.github.atengk.module.system.export.enums.ExportTaskStatusEnum;
import io.github.atengk.module.system.export.service.ExportTaskService;
import io.github.atengk.module.system.user.query.UserPageQuery;
import io.github.atengk.module.system.user.service.UserAsyncExportExecutor;
import io.github.atengk.module.system.user.service.UserExportFileService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* 用户异步导出执行器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserAsyncExportExecutorImpl implements UserAsyncExportExecutor {
private final ExportTaskService exportTaskService;
private final UserExportFileService userExportFileService;
/**
* 执行用户导出任务
*
* @param taskId 任务 ID
* @param query 查询参数
*/
@Async("bizTaskExecutor")
@Override
public void executeUserExport(Long taskId, UserPageQuery query) {
try {
exportTaskService.updateStatus(taskId, ExportTaskStatusEnum.PROCESSING.getCode(), null);
Long fileId = userExportFileService.generateUserExportFile(taskId, query);
exportTaskService.finishTask(taskId, fileId);
log.info("用户异步导出任务完成,任务ID:{},文件ID:{}", taskId, fileId);
} catch (Exception exception) {
exportTaskService.updateStatus(taskId, ExportTaskStatusEnum.FAILED.getCode(), exception.getMessage());
log.error("用户异步导出任务失败,任务ID:{}", taskId, exception);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
异步导出建议:
| 场景 | 建议 |
|---|---|
| 大数据导出 | 使用异步任务 |
| 任务状态 | 等待中、处理中、成功、失败 |
| 文件保存 | 生成后上传文件服务 |
| 下载权限 | 只能下载自己的任务文件,管理员除外 |
| 任务过期 | 定期清理过期文件 |
| 并发限制 | 限制同一用户同时导出任务数量 |
| 失败原因 | 记录错误摘要,不记录敏感堆栈给前端 |
导出任务记录
导出任务记录用于追踪异步导出的状态和文件结果。
导出任务表:
CREATE TABLE sys_export_task (
id BIGINT NOT NULL COMMENT '任务ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
task_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '任务名称',
task_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '任务类型',
task_status TINYINT NOT NULL DEFAULT 0 COMMENT '任务状态:0等待中,1处理中,2成功,3失败',
query_param TEXT NULL COMMENT '查询参数',
file_id BIGINT NOT NULL DEFAULT 0 COMMENT '导出文件ID',
fail_reason VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '失败原因',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
KEY idx_tenant_user_time (tenant_id, create_by, create_time),
KEY idx_status_time (task_status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='导出任务表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
导出任务状态枚举。
package io.github.atengk.module.system.export.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 导出任务状态枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum ExportTaskStatusEnum {
/**
* 等待中
*/
WAITING(0, "等待中"),
/**
* 处理中
*/
PROCESSING(1, "处理中"),
/**
* 成功
*/
SUCCESS(2, "成功"),
/**
* 失败
*/
FAILED(3, "失败");
/**
* 状态编码
*/
private final Integer code;
/**
* 状态名称
*/
private final String name;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
导出任务记录建议:
| 字段 | 建议 |
|---|---|
task_type | 区分用户导出、订单导出、日志导出 |
task_status | 记录任务状态 |
query_param | 保存查询条件,注意脱敏 |
file_id | 导出成功后关联文件 |
fail_reason | 失败摘要,截断长度 |
create_by | 任务创建人 |
tenant_id | 多租户隔离 |
| 清理策略 | 过期任务和文件定期清理 |
文件与附件关联
文件与附件关联用于解决“文件上传后如何绑定业务数据”的问题。文件表保存文件元数据,附件关系表保存业务对象与文件之间的关系。不要把文件 ID 直接堆在业务表的字符串字段里,例如 file_ids = 1,2,3,这种设计不利于查询、权限控制和删除联动。
附件表设计
建议拆分为文件表和附件关系表。文件表记录物理文件元数据,附件表记录文件与业务对象的绑定关系。
文件表:
CREATE TABLE sys_file (
id BIGINT NOT NULL COMMENT '文件ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
file_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '原始文件名',
file_ext VARCHAR(32) NOT NULL DEFAULT '' COMMENT '文件扩展名',
file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小,单位字节',
content_type VARCHAR(128) NOT NULL DEFAULT '' COMMENT '文件类型',
storage_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '存储类型:local、minio、oss',
bucket_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '存储桶',
object_name VARCHAR(500) NOT NULL DEFAULT '' COMMENT '对象名称',
access_url VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '访问地址',
file_hash VARCHAR(128) NOT NULL DEFAULT '' COMMENT '文件哈希',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '上传人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
KEY idx_tenant_hash (tenant_id, file_hash),
KEY idx_tenant_user_time (tenant_id, create_by, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统文件表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
附件关系表:
CREATE TABLE sys_attachment (
id BIGINT NOT NULL COMMENT '附件ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
business_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '业务类型',
business_id BIGINT NOT NULL DEFAULT 0 COMMENT '业务ID',
file_id BIGINT NOT NULL DEFAULT 0 COMMENT '文件ID',
attachment_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '附件名称',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
PRIMARY KEY (id),
UNIQUE KEY uk_biz_file (tenant_id, business_type, business_id, file_id),
KEY idx_biz (tenant_id, business_type, business_id),
KEY idx_file_id (file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务附件关系表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
附件关系实体。
package io.github.atengk.module.system.file.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import io.github.atengk.common.entity.TenantBaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 业务附件实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_attachment")
public class AttachmentEntity extends TenantBaseEntity {
/**
* 业务类型
*/
private String businessType;
/**
* 业务 ID
*/
private Long businessId;
/**
* 文件 ID
*/
private Long fileId;
/**
* 附件名称
*/
private String attachmentName;
/**
* 排序
*/
private Integer sortOrder;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
附件表设计建议:
| 设计项 | 建议 |
|---|---|
| 文件元数据 | 放 sys_file |
| 业务绑定 | 放 sys_attachment |
| 业务类型 | 使用稳定编码,例如 ORDER_CONTRACT |
| 业务 ID | 保存业务主表 ID |
| 文件 ID | 关联 sys_file.id |
| 唯一约束 | (tenant_id, business_type, business_id, file_id) |
| 排序 | 使用 sort_order |
| 删除 | 优先逻辑删除关系,再处理物理文件 |
业务表与附件关系
业务表不建议直接保存多个附件 ID。推荐业务表通过 business_type + business_id 查询附件关系。例如合同附件使用 business_type = CONTRACT,订单附件使用 business_type = ORDER。
业务附件类型枚举。
package io.github.atengk.module.system.file.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 附件业务类型枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum AttachmentBusinessTypeEnum {
/**
* 用户头像
*/
USER_AVATAR("USER_AVATAR", "用户头像"),
/**
* 订单附件
*/
ORDER_ATTACHMENT("ORDER_ATTACHMENT", "订单附件"),
/**
* 合同附件
*/
CONTRACT_ATTACHMENT("CONTRACT_ATTACHMENT", "合同附件"),
/**
* 审批附件
*/
APPROVAL_ATTACHMENT("APPROVAL_ATTACHMENT", "审批附件");
/**
* 类型编码
*/
private final String code;
/**
* 类型名称
*/
private final String name;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
附件 VO。
package io.github.atengk.module.system.file.vo;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 附件返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class AttachmentVO {
/**
* 附件 ID
*/
private Long id;
/**
* 文件 ID
*/
private Long fileId;
/**
* 附件名称
*/
private String attachmentName;
/**
* 文件大小
*/
private Long fileSize;
/**
* 文件类型
*/
private String contentType;
/**
* 预览地址
*/
private String previewUrl;
/**
* 创建时间
*/
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Mapper 查询业务附件。
<select id="selectAttachmentsByBusiness" resultType="io.github.atengk.module.system.file.vo.AttachmentVO">
SELECT
a.id,
a.file_id,
a.attachment_name,
f.file_size,
f.content_type,
f.access_url AS preview_url,
a.create_time
FROM sys_attachment a
INNER JOIN sys_file f ON f.id = a.file_id AND f.deleted = 0
WHERE a.tenant_id = #{tenantId}
AND a.business_type = #{businessType}
AND a.business_id = #{businessId}
AND a.deleted = 0
ORDER BY a.sort_order ASC, a.id ASC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
业务关系建议:
| 场景 | 建议 |
|---|---|
| 一个业务多个附件 | 使用附件关系表 |
| 一个文件绑定多个业务 | 允许多条附件关系 |
| 业务删除 | 删除附件关系,不一定删除物理文件 |
| 文件复用 | 通过 file_id 复用 |
| 查询附件 | 按 business_type + business_id 查询 |
| 业务详情 | 批量回显附件列表 |
文件上传记录
文件上传记录保存文件元数据。上传成功后先写 sys_file,业务保存成功后再绑定 sys_attachment。这样可以支持“先上传,后提交表单”的前端交互。
文件实体。
package io.github.atengk.module.system.file.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import io.github.atengk.common.entity.TenantBaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 系统文件实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_file")
public class FileEntity extends TenantBaseEntity {
/**
* 原始文件名
*/
private String fileName;
/**
* 文件扩展名
*/
private String fileExt;
/**
* 文件大小
*/
private Long fileSize;
/**
* 文件类型
*/
private String contentType;
/**
* 存储类型
*/
private String storageType;
/**
* 存储桶
*/
private String bucketName;
/**
* 对象名称
*/
private String objectName;
/**
* 访问地址
*/
private String accessUrl;
/**
* 文件哈希
*/
private String fileHash;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
上传服务示例。
package io.github.atengk.module.system.file.service.impl;
import cn.hutool.core.io.FileUtil;
import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.module.system.file.entity.FileEntity;
import io.github.atengk.module.system.file.mapper.FileMapper;
import io.github.atengk.module.system.file.service.FileStorageService;
import io.github.atengk.module.system.file.service.FileUploadService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* 文件上传服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FileUploadServiceImpl extends ServiceImpl<FileMapper, FileEntity> implements FileUploadService {
private final FileStorageService fileStorageService;
private final CurrentUserContext currentUserContext;
/**
* 上传文件
*
* @param file 上传文件
* @return 文件 ID
*/
@Override
public Long upload(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException("上传文件不能为空");
}
String originalFilename = file.getOriginalFilename();
String fileExt = FileUtil.extName(originalFilename);
String fileHash = SecureUtil.md5(file.getInputStream());
String objectName = fileStorageService.upload(file);
FileEntity entity = new FileEntity();
entity.setTenantId(currentUserContext.getTenantIdOrDefault());
entity.setFileName(originalFilename);
entity.setFileExt(fileExt);
entity.setFileSize(file.getSize());
entity.setContentType(file.getContentType());
entity.setStorageType(fileStorageService.getStorageType());
entity.setBucketName(fileStorageService.getBucketName());
entity.setObjectName(objectName);
entity.setAccessUrl(fileStorageService.getAccessUrl(objectName));
entity.setFileHash(fileHash);
this.save(entity);
log.info("文件上传成功,文件ID:{},文件名:{}", entity.getId(), originalFilename);
return entity.getId();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
文件上传记录建议:
| 场景 | 建议 |
|---|---|
| 上传成功 | 先写文件元数据 |
| 表单未提交 | 文件可能成为临时文件,需要定期清理 |
| 文件哈希 | 可用于秒传或重复识别 |
| 访问地址 | 可保存永久路径,也可动态生成临时地址 |
| 文件名 | 原始文件名只用于展示,不作为对象名 |
| 对象名 | 使用 ID、UUID、日期路径生成 |
| 安全 | 校验扩展名、MIME、大小 |
文件删除联动
文件删除要区分“解除业务绑定”和“删除物理文件”。业务删除时通常只删除附件关系,不立即删除 sys_file 和物理文件,因为同一个文件可能被多个业务引用。
解除业务附件绑定:
package io.github.atengk.module.system.file.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.module.system.file.entity.AttachmentEntity;
import io.github.atengk.module.system.file.mapper.AttachmentMapper;
import io.github.atengk.module.system.file.service.AttachmentService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 附件服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AttachmentServiceImpl extends ServiceImpl<AttachmentMapper, AttachmentEntity> implements AttachmentService {
private final CurrentUserContext currentUserContext;
/**
* 删除业务附件关系
*
* @param businessType 业务类型
* @param businessId 业务 ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void removeByBusiness(String businessType, Long businessId) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
boolean removed = this.lambdaUpdate()
.eq(AttachmentEntity::getTenantId, tenantId)
.eq(AttachmentEntity::getBusinessType, businessType)
.eq(AttachmentEntity::getBusinessId, businessId)
.remove();
log.info("删除业务附件关系完成,租户ID:{},业务类型:{},业务ID:{},结果:{}",
tenantId, businessType, businessId, removed);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
物理文件清理建议通过定时任务处理:查询没有任何附件关系引用、且创建时间较早的文件,再删除物理文件和文件记录。
package io.github.atengk.module.system.file.job;
import io.github.atengk.module.system.file.service.FileCleanService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 文件清理定时任务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FileCleanJob {
private final FileCleanService fileCleanService;
/**
* 每天凌晨清理无引用临时文件
*/
@Scheduled(cron = "0 30 2 * * ?")
public void cleanUnboundFiles() {
int count = fileCleanService.cleanUnboundFiles();
log.info("清理无引用文件完成,数量:{}", count);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
文件删除联动建议:
| 场景 | 建议 |
|---|---|
| 删除业务数据 | 删除附件关系 |
| 删除附件 | 删除附件关系,不一定删物理文件 |
| 文件无引用 | 定时任务清理 |
| 物理文件删除失败 | 记录失败,后续重试 |
| 多业务复用文件 | 删除前检查引用数量 |
| 敏感文件 | 可立即删除物理文件,但要校验引用 |
| 操作日志 | 删除附件和文件清理都应记录 |
文件预览地址回显
文件预览地址可以直接保存,也可以运行时生成临时 URL。对象存储场景下,推荐运行时生成带有效期的预览地址,避免永久公开访问。
文件预览服务接口:
package io.github.atengk.module.system.file.service;
/**
* 文件预览服务
*
* @author Ateng
* @since 2026-05-05
*/
public interface FilePreviewService {
/**
* 获取文件预览地址
*
* @param fileId 文件 ID
* @return 预览地址
*/
String getPreviewUrl(Long fileId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
预览服务实现。
package io.github.atengk.module.system.file.service.impl;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.module.system.file.entity.FileEntity;
import io.github.atengk.module.system.file.service.FilePreviewService;
import io.github.atengk.module.system.file.service.FileService;
import io.github.atengk.module.system.file.service.FileStorageService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 文件预览服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Service
@RequiredArgsConstructor
public class FilePreviewServiceImpl implements FilePreviewService {
private final FileService fileService;
private final FileStorageService fileStorageService;
/**
* 获取文件预览地址
*
* @param fileId 文件 ID
* @return 预览地址
*/
@Override
public String getPreviewUrl(Long fileId) {
FileEntity file = fileService.getById(fileId);
if (file == null) {
throw new BusinessException("文件不存在");
}
if (StrUtil.isNotBlank(file.getAccessUrl())) {
return file.getAccessUrl();
}
return fileStorageService.generatePreviewUrl(file.getObjectName());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
预览地址建议:
| 场景 | 建议 |
|---|---|
| 公共图片 | 可以返回公开访问地址 |
| 私有附件 | 返回临时签名 URL |
| 敏感文件 | 下载和预览都要鉴权 |
| 地址过期 | 前端需要重新获取 |
| 文件不存在 | 返回明确错误 |
| 列表回显 | 批量生成时注意性能 |
| 大文件预览 | 建议通过专门预览服务处理 |
附件批量绑定
附件批量绑定用于前端“先上传文件,提交业务表单时传 fileIds”的场景。Service 在业务保存成功后,把 fileIds 绑定到业务对象。
批量绑定请求对象:
package io.github.atengk.module.system.file.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 附件批量绑定参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class AttachmentBindDTO {
/**
* 业务类型
*/
@NotNull(message = "业务类型不能为空")
private String businessType;
/**
* 业务 ID
*/
@NotNull(message = "业务ID不能为空")
private Long businessId;
/**
* 文件 ID 列表
*/
@NotEmpty(message = "文件ID列表不能为空")
@Size(max = 20, message = "单次最多绑定20个附件")
private List<@NotNull(message = "文件ID不能为空") Long> fileIds;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
附件绑定服务。
package io.github.atengk.module.system.file.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.module.system.file.entity.AttachmentEntity;
import io.github.atengk.module.system.file.entity.FileEntity;
import io.github.atengk.module.system.file.mapper.AttachmentMapper;
import io.github.atengk.module.system.file.service.AttachmentBindService;
import io.github.atengk.module.system.file.service.FileService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 附件批量绑定服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AttachmentBindServiceImpl extends ServiceImpl<AttachmentMapper, AttachmentEntity>
implements AttachmentBindService {
private final FileService fileService;
private final CurrentUserContext currentUserContext;
/**
* 批量绑定附件
*
* @param businessType 业务类型
* @param businessId 业务 ID
* @param fileIds 文件 ID 列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void bindAttachments(String businessType, Long businessId, List<Long> fileIds) {
if (CollUtil.isEmpty(fileIds)) {
return;
}
Long tenantId = currentUserContext.getTenantIdOrDefault();
List<Long> distinctFileIds = fileIds.stream().distinct().toList();
List<FileEntity> files = fileService.lambdaQuery()
.eq(FileEntity::getTenantId, tenantId)
.in(FileEntity::getId, distinctFileIds)
.list();
if (files.size() != distinctFileIds.size()) {
throw new BusinessException("存在无效文件或无权限访问的文件");
}
this.lambdaUpdate()
.eq(AttachmentEntity::getTenantId, tenantId)
.eq(AttachmentEntity::getBusinessType, businessType)
.eq(AttachmentEntity::getBusinessId, businessId)
.remove();
List<AttachmentEntity> attachments = files.stream()
.map(file -> {
AttachmentEntity entity = new AttachmentEntity();
entity.setTenantId(tenantId);
entity.setBusinessType(businessType);
entity.setBusinessId(businessId);
entity.setFileId(file.getId());
entity.setAttachmentName(ObjectUtil.defaultIfNull(file.getFileName(), ""));
entity.setSortOrder(distinctFileIds.indexOf(file.getId()));
return entity;
})
.toList();
this.saveBatch(attachments, 1000);
log.info("批量绑定附件成功,租户ID:{},业务类型:{},业务ID:{},数量:{}",
tenantId, businessType, businessId, attachments.size());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
业务保存时绑定附件:
@Transactional(rollbackFor = Exception.class)
public Long addContract(ContractAddDTO dto) {
ContractEntity entity = contractConvert.toEntity(dto);
contractService.save(entity);
attachmentBindService.bindAttachments(
AttachmentBusinessTypeEnum.CONTRACT_ATTACHMENT.getCode(),
entity.getId(),
dto.getFileIds()
);
log.info("新增合同并绑定附件成功,合同ID:{},附件数量:{}",
entity.getId(), CollUtil.size(dto.getFileIds()));
return entity.getId();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
附件批量绑定建议:
| 场景 | 建议 |
|---|---|
| 新增业务 | 业务保存成功后绑定附件 |
| 修改业务 | 可先删旧关系再插新关系 |
| 文件 ID | 去重后处理 |
| 文件权限 | 校验文件属于当前租户和当前用户可访问 |
| 绑定数量 | 限制最大数量 |
| 排序 | 按前端传入顺序保存 sort_order |
| 事务 | 业务保存和附件绑定放同一事务 |
附件权限控制
附件权限控制必须覆盖上传、预览、下载、删除、绑定。不能只依赖文件 URL 是否隐蔽。私有文件必须通过后端鉴权后返回临时地址或文件流。
附件权限服务接口:
package io.github.atengk.module.system.file.service;
/**
* 附件权限服务
*
* @author Ateng
* @since 2026-05-05
*/
public interface AttachmentPermissionService {
/**
* 判断是否有文件访问权限
*
* @param fileId 文件 ID
* @return 是否有权限
*/
boolean hasFileAccessPermission(Long fileId);
/**
* 判断是否有业务附件访问权限
*
* @param businessType 业务类型
* @param businessId 业务 ID
* @return 是否有权限
*/
boolean hasBusinessAttachmentPermission(String businessType, Long businessId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
权限校验示例。
package io.github.atengk.module.system.file.service.impl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.module.system.file.entity.FileEntity;
import io.github.atengk.module.system.file.service.AttachmentPermissionService;
import io.github.atengk.module.system.file.service.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 附件权限服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Service
@RequiredArgsConstructor
public class AttachmentPermissionServiceImpl implements AttachmentPermissionService {
private final FileService fileService;
private final CurrentUserContext currentUserContext;
/**
* 判断是否有文件访问权限
*
* @param fileId 文件 ID
* @return 是否有权限
*/
@Override
public boolean hasFileAccessPermission(Long fileId) {
FileEntity file = fileService.getById(fileId);
if (file == null) {
return false;
}
if (!file.getTenantId().equals(currentUserContext.getTenantIdOrDefault())) {
return false;
}
if (currentUserContext.isSuperAdmin()) {
return true;
}
return file.getCreateBy().equals(currentUserContext.getUserIdOrDefault());
}
/**
* 判断是否有业务附件访问权限
*
* @param businessType 业务类型
* @param businessId 业务 ID
* @return 是否有权限
*/
@Override
public boolean hasBusinessAttachmentPermission(String businessType, Long businessId) {
// 这里根据业务类型路由到不同业务权限校验服务
// 例如订单附件校验订单访问权限,合同附件校验合同访问权限
return businessId != null && currentUserContext.getLoginUserOrDefault() != null;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
下载前校验权限。
@GetMapping("/files/{fileId}/preview")
public ApiResult<String> getPreviewUrl(@PathVariable Long fileId) {
if (!attachmentPermissionService.hasFileAccessPermission(fileId)) {
throw new BusinessException("无权限访问该文件");
}
String previewUrl = filePreviewService.getPreviewUrl(fileId);
return ApiResult.success(previewUrl);
}2
3
4
5
6
7
8
9
附件权限控制建议:
| 操作 | 权限要求 |
|---|---|
| 上传 | 必须登录,限制文件类型和大小 |
| 预览 | 校验文件所属租户和业务访问权限 |
| 下载 | 校验文件所属租户和业务访问权限 |
| 删除附件 | 校验业务编辑权限 |
| 绑定附件 | 校验文件访问权限和业务编辑权限 |
| 导出附件 | 记录操作日志 |
| 公共文件 | 明确标记公开范围 |
| 私有文件 | 使用临时签名 URL 或后端流式输出 |
文件和附件的核心边界是:文件表只表示“文件存在”,附件表表示“文件属于某个业务对象”。权限控制不能只看文件创建人,还要看业务对象是否允许当前用户访问。
缓存集成
缓存集成用于降低数据库访问压力、提升热点数据读取速度。Spring Boot 提供 Cache 抽象,真正的缓存存储由 CacheManager 提供;当 Redis 可用并完成配置时,Spring Boot 可以自动配置 RedisCacheManager,也可以通过 spring.cache.type=redis 明确指定缓存类型。(Home)
缓存不要无差别使用。适合缓存的是读多写少、变更频率低、允许短时间延迟的数据,例如字典、参数配置、用户权限、数据权限范围、菜单树、部门树、热点详情。不适合缓存的是强一致库存、实时余额、频繁变化状态、事务中间态数据。
Redis 缓存场景
Redis 缓存常用于查询结果缓存、字典缓存、用户登录信息、权限缓存、数据权限缓存、验证码、幂等 Token、分布式锁、限流计数、异步任务状态等场景。
常见缓存场景如下:
| 场景 | 是否推荐 Redis | 说明 |
|---|---|---|
| 字典数据 | 推荐 | 读多写少,更新后主动清理 |
| 参数配置 | 推荐 | 读多写少,适合长 TTL |
| 用户权限 | 推荐 | 登录后缓存,角色变更后清理 |
| 数据权限范围 | 推荐 | 避免每次查询递归部门树 |
| 菜单树 | 推荐 | 用户或角色维度缓存 |
| 热点详情 | 可用 | 注意更新一致性 |
| 分页列表 | 谨慎 | 查询条件多,Key 容易膨胀 |
| 库存数量 | 谨慎 | 强一致场景不应只依赖缓存 |
| 订单状态 | 谨慎 | 状态变更频繁时一致性复杂 |
| 验证码 | 推荐 | 短 TTL |
| 幂等 Token | 推荐 | 短 TTL + 原子删除 |
引入 Redis 和 Cache 依赖。
<!-- Spring Cache 抽象支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Redis 客户端与 RedisTemplate 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Hutool 工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Redis 基础配置如下。
spring:
data:
redis:
# Redis 地址
host: 127.0.0.1
# Redis 端口
port: 6379
# Redis 数据库
database: 0
# Redis 密码,没有密码可不配置
password:
# 连接超时时间
timeout: 3s
lettuce:
pool:
# 最大连接数
max-active: 16
# 最大空闲连接
max-idle: 8
# 最小空闲连接
min-idle: 2
# 获取连接最大等待时间
max-wait: 3s
cache:
# 明确使用 Redis 作为 Spring Cache 实现
type: redis
redis:
# 默认缓存过期时间
time-to-live: 30m
# 建议保留 key 前缀,避免不同 cacheName 下 key 冲突
use-key-prefix: true
# 不缓存 null,由业务单独处理空值缓存
cache-null-values: false2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
RedisTemplate 配置类如下。
文件位置:src/main/java/io/github/atengk/framework/redis/RedisConfig.java
package io.github.atengk.framework.redis;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;
/**
* Redis 配置
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@EnableCaching
@Configuration
public class RedisConfig {
/**
* 配置 RedisTemplate 序列化方式
*
* @param connectionFactory Redis 连接工厂
* @return RedisTemplate
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
ObjectMapper objectMapper = JsonMapper.builder()
.addModule(new JavaTimeModule())
.activateDefaultTyping(
JsonMapper.builder().build().getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
)
.build();
GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);
StringRedisSerializer keySerializer = new StringRedisSerializer();
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
// key 使用字符串,便于排查
redisTemplate.setKeySerializer(keySerializer);
redisTemplate.setHashKeySerializer(keySerializer);
// value 使用 JSON,支持 LocalDateTime
redisTemplate.setValueSerializer(valueSerializer);
redisTemplate.setHashValueSerializer(valueSerializer);
redisTemplate.afterPropertiesSet();
log.info("RedisTemplate 初始化完成");
return redisTemplate;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
查询缓存
查询缓存适合缓存热点详情、固定条件列表、配置项、少量维表数据。分页查询不建议默认缓存,因为查询条件组合多、缓存 Key 数量容易失控。
缓存 Key 建议集中管理。
文件位置:src/main/java/io/github/atengk/framework/redis/CacheKeys.java
package io.github.atengk.framework.redis;
import cn.hutool.core.util.StrUtil;
/**
* 缓存 Key 常量
*
* @author Ateng
* @since 2026-05-05
*/
public final class CacheKeys {
private static final String USER_DETAIL = "user:detail:{}:{}";
private static final String DICT_TYPE = "dict:type:{}:{}";
private static final String DATA_SCOPE = "data:scope:{}:{}";
private CacheKeys() {
}
/**
* 用户详情缓存 Key
*
* @param tenantId 租户 ID
* @param userId 用户 ID
* @return 缓存 Key
*/
public static String userDetail(Long tenantId, Long userId) {
return StrUtil.format(USER_DETAIL, tenantId, userId);
}
/**
* 字典类型缓存 Key
*
* @param tenantId 租户 ID
* @param dictType 字典类型
* @return 缓存 Key
*/
public static String dictType(Long tenantId, String dictType) {
return StrUtil.format(DICT_TYPE, tenantId, dictType);
}
/**
* 数据权限缓存 Key
*
* @param tenantId 租户 ID
* @param userId 用户 ID
* @return 缓存 Key
*/
public static String dataScope(Long tenantId, Long userId) {
return StrUtil.format(DATA_SCOPE, tenantId, userId);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
通用 Redis 缓存工具封装。
文件位置:src/main/java/io/github/atengk/framework/redis/RedisCacheService.java
package io.github.atengk.framework.redis;
import cn.hutool.core.util.ObjectUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.function.Supplier;
/**
* Redis 缓存服务
*
* @author Ateng
* @since 2026-05-05
*/
@Service
@RequiredArgsConstructor
public class RedisCacheService {
private final RedisTemplate<String, Object> redisTemplate;
/**
* 获取缓存对象
*
* @param key 缓存 Key
* @param type 对象类型
* @param <T> 对象类型
* @return 缓存对象
*/
public <T> T get(String key, Class<T> type) {
Object value = redisTemplate.opsForValue().get(key);
if (ObjectUtil.isNull(value)) {
return null;
}
return type.cast(value);
}
/**
* 设置缓存对象
*
* @param key 缓存 Key
* @param value 缓存值
* @param ttl 过期时间
*/
public void set(String key, Object value, Duration ttl) {
redisTemplate.opsForValue().set(key, value, ttl);
}
/**
* 删除缓存
*
* @param key 缓存 Key
*/
public void delete(String key) {
redisTemplate.delete(key);
}
/**
* 缓存不存在时从数据源加载
*
* @param key 缓存 Key
* @param type 对象类型
* @param ttl 过期时间
* @param loader 数据加载器
* @param <T> 对象类型
* @return 数据对象
*/
public <T> T getOrLoad(String key, Class<T> type, Duration ttl, Supplier<T> loader) {
T cachedValue = get(key, type);
if (ObjectUtil.isNotNull(cachedValue)) {
return cachedValue;
}
T loadedValue = loader.get();
if (ObjectUtil.isNotNull(loadedValue)) {
set(key, loadedValue, ttl);
}
return loadedValue;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
用户详情查询缓存示例。
public UserDetailVO getUserDetail(Long userId) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
String cacheKey = CacheKeys.userDetail(tenantId, userId);
return redisCacheService.getOrLoad(cacheKey, UserDetailVO.class, Duration.ofMinutes(30), () -> {
UserEntity entity = this.lambdaQuery()
.eq(UserEntity::getTenantId, tenantId)
.eq(UserEntity::getId, userId)
.one();
if (entity == null) {
return null;
}
UserDetailVO detailVO = userConvert.toDetailVO(entity);
log.info("从数据库加载用户详情,租户ID:{},用户ID:{}", tenantId, userId);
return detailVO;
});
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
查询缓存建议:
| 场景 | 建议 |
|---|---|
| 热点详情 | 可缓存 |
| 字典、参数 | 推荐缓存 |
| 用户权限 | 推荐缓存 |
| 分页列表 | 谨慎缓存 |
| 大对象 | 不建议缓存完整大对象 |
| 查询条件复杂 | 不建议缓存 |
| 变更频繁数据 | 不建议缓存 |
| 强一致数据 | 谨慎使用缓存 |
字典缓存
字典缓存是最典型的 Redis 缓存场景。字典数据变更频率低,读取频率高,适合按 tenantId + dictType 缓存。
字典缓存服务如下。
package io.github.atengk.module.system.dict.service.impl;
import cn.hutool.core.collection.CollUtil;
import io.github.atengk.common.dict.DictItemVO;
import io.github.atengk.framework.redis.CacheKeys;
import io.github.atengk.framework.redis.RedisCacheService;
import io.github.atengk.module.system.dict.mapper.DictDataMapper;
import io.github.atengk.module.system.dict.service.DictCacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.List;
/**
* 字典缓存服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DictCacheServiceImpl implements DictCacheService {
private static final Duration DICT_TTL = Duration.ofHours(6);
private final RedisCacheService redisCacheService;
private final DictDataMapper dictDataMapper;
/**
* 查询字典项
*
* @param tenantId 租户 ID
* @param dictType 字典类型
* @return 字典项列表
*/
@Override
@SuppressWarnings("unchecked")
public List<DictItemVO> listDictItems(Long tenantId, String dictType) {
String cacheKey = CacheKeys.dictType(tenantId, dictType);
List<DictItemVO> cachedItems = redisCacheService.get(cacheKey, List.class);
if (CollUtil.isNotEmpty(cachedItems)) {
return cachedItems;
}
List<DictItemVO> records = dictDataMapper.selectEnabledDictItems(tenantId, dictType);
redisCacheService.set(cacheKey, records, DICT_TTL);
log.info("加载字典缓存,租户ID:{},字典类型:{},数量:{}", tenantId, dictType, records.size());
return records;
}
/**
* 清理字典缓存
*
* @param tenantId 租户 ID
* @param dictType 字典类型
*/
@Override
public void clearDictCache(Long tenantId, String dictType) {
redisCacheService.delete(CacheKeys.dictType(tenantId, dictType));
log.info("清理字典缓存,租户ID:{},字典类型:{}", tenantId, dictType);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
字典变更后主动删除缓存。
@Transactional(rollbackFor = Exception.class)
public void updateDictData(DictDataUpdateDTO dto) {
DictDataEntity entity = this.getRequiredById(dto.getId());
dictDataConvert.updateEntity(dto, entity);
this.updateByIdRequired(entity);
dictCacheService.clearDictCache(entity.getTenantId(), entity.getDictType());
log.info("修改字典数据成功,字典ID:{},字典类型:{}", entity.getId(), entity.getDictType());
}2
3
4
5
6
7
8
9
10
用户缓存
用户缓存通常包括用户详情、登录用户信息、角色编码、权限标识、菜单树等。用户缓存必须考虑权限变更、角色变更、部门变更、用户禁用、密码修改等失效场景。
用户权限缓存对象如下。
package io.github.atengk.framework.security;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.util.Set;
/**
* 用户权限缓存对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserPermissionCache implements Serializable {
/**
* 用户 ID
*/
private Long userId;
/**
* 租户 ID
*/
private Long tenantId;
/**
* 角色编码集合
*/
private Set<String> roleCodes;
/**
* 权限标识集合
*/
private Set<String> permissions;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
用户权限缓存服务。
package io.github.atengk.framework.security;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.framework.redis.RedisCacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* 用户权限缓存服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserPermissionCacheService {
private static final String USER_PERMISSION_KEY = "user:permission:{}:{}";
private static final Duration USER_PERMISSION_TTL = Duration.ofHours(2);
private final RedisCacheService redisCacheService;
/**
* 设置用户权限缓存
*
* @param cache 用户权限缓存
*/
public void setPermissionCache(UserPermissionCache cache) {
String key = getKey(cache.getTenantId(), cache.getUserId());
redisCacheService.set(key, cache, USER_PERMISSION_TTL);
log.info("设置用户权限缓存,租户ID:{},用户ID:{}", cache.getTenantId(), cache.getUserId());
}
/**
* 获取用户权限缓存
*
* @param tenantId 租户 ID
* @param userId 用户 ID
* @return 用户权限缓存
*/
public UserPermissionCache getPermissionCache(Long tenantId, Long userId) {
return redisCacheService.get(getKey(tenantId, userId), UserPermissionCache.class);
}
/**
* 删除用户权限缓存
*
* @param tenantId 租户 ID
* @param userId 用户 ID
*/
public void deletePermissionCache(Long tenantId, Long userId) {
redisCacheService.delete(getKey(tenantId, userId));
log.info("删除用户权限缓存,租户ID:{},用户ID:{}", tenantId, userId);
}
/**
* 获取缓存 Key
*
* @param tenantId 租户 ID
* @param userId 用户 ID
* @return 缓存 Key
*/
private String getKey(Long tenantId, Long userId) {
return StrUtil.format(USER_PERMISSION_KEY, tenantId, userId);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
用户缓存失效建议:
| 变更场景 | 处理方式 |
|---|---|
| 修改用户状态 | 删除用户详情和权限缓存 |
| 修改用户角色 | 删除用户权限缓存 |
| 修改角色权限 | 删除该角色下所有用户权限缓存 |
| 修改菜单权限 | 删除相关用户权限缓存 |
| 修改部门 | 删除用户详情和数据权限缓存 |
| 修改密码 | 删除登录态或强制重新登录 |
| 禁用用户 | 删除登录态和权限缓存 |
数据权限缓存
数据权限缓存用于缓存用户可访问部门 ID、角色数据范围、自定义数据范围等。它可以避免每次查询数据权限时递归部门树或查询角色部门关系。
数据权限缓存对象如下。
package io.github.atengk.framework.security;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.util.List;
/**
* 数据权限缓存对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class DataScopeCache implements Serializable {
/**
* 用户 ID
*/
private Long userId;
/**
* 租户 ID
*/
private Long tenantId;
/**
* 数据范围类型
*/
private Integer dataScope;
/**
* 可访问部门 ID 列表
*/
private List<Long> deptIds;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
数据权限缓存服务。
package io.github.atengk.framework.security;
import io.github.atengk.framework.redis.CacheKeys;
import io.github.atengk.framework.redis.RedisCacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* 数据权限缓存服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataScopeCacheService {
private static final Duration DATA_SCOPE_TTL = Duration.ofHours(1);
private final RedisCacheService redisCacheService;
/**
* 获取数据权限缓存
*
* @param tenantId 租户 ID
* @param userId 用户 ID
* @return 数据权限缓存
*/
public DataScopeCache getDataScopeCache(Long tenantId, Long userId) {
return redisCacheService.get(CacheKeys.dataScope(tenantId, userId), DataScopeCache.class);
}
/**
* 设置数据权限缓存
*
* @param cache 数据权限缓存
*/
public void setDataScopeCache(DataScopeCache cache) {
redisCacheService.set(CacheKeys.dataScope(cache.getTenantId(), cache.getUserId()), cache, DATA_SCOPE_TTL);
log.info("设置数据权限缓存,租户ID:{},用户ID:{}", cache.getTenantId(), cache.getUserId());
}
/**
* 删除数据权限缓存
*
* @param tenantId 租户 ID
* @param userId 用户 ID
*/
public void deleteDataScopeCache(Long tenantId, Long userId) {
redisCacheService.delete(CacheKeys.dataScope(tenantId, userId));
log.info("删除数据权限缓存,租户ID:{},用户ID:{}", tenantId, userId);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
数据权限缓存失效建议:
| 变更场景 | 处理方式 |
|---|---|
| 用户部门变更 | 删除该用户数据权限缓存 |
| 角色数据范围变更 | 删除角色下所有用户数据权限缓存 |
| 部门树调整 | 删除受影响用户数据权限缓存 |
| 用户角色变更 | 删除该用户数据权限缓存 |
| 租户切换 | 缓存 Key 必须包含租户 ID |
| 超级管理员 | 可不缓存或短 TTL 缓存 |
缓存更新策略
缓存更新策略主要有三类:先删缓存再更新数据库、先更新数据库再删缓存、延迟双删。普通业务项目推荐“先更新数据库,事务提交后删除缓存”。
不要在事务未提交时提前删除缓存,否则其他线程可能读取旧数据库数据并重新写入缓存。
推荐使用事务提交后删除缓存。Spring 的 @TransactionalEventListener 默认阶段是 AFTER_COMMIT,即事务提交后处理事件;如果没有事务,事件默认不会被处理,除非显式启用 fallbackExecution。(Home)
定义缓存清理事件。
package io.github.atengk.framework.cache;
import lombok.Getter;
/**
* 缓存清理事件
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
public class CacheEvictEvent {
/**
* 缓存 Key
*/
private final String cacheKey;
public CacheEvictEvent(String cacheKey) {
this.cacheKey = cacheKey;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
事务提交后清理缓存。
package io.github.atengk.framework.cache;
import io.github.atengk.framework.redis.RedisCacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
/**
* 缓存清理事件监听器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CacheEvictEventListener {
private final RedisCacheService redisCacheService;
/**
* 事务提交后删除缓存
*
* @param event 缓存清理事件
*/
@TransactionalEventListener
public void handleCacheEvict(CacheEvictEvent event) {
redisCacheService.delete(event.getCacheKey());
log.info("事务提交后清理缓存,缓存Key:{}", event.getCacheKey());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
业务修改后发布缓存清理事件。
@Transactional(rollbackFor = Exception.class)
public void updateUser(UserUpdateDTO dto) {
UserEntity entity = this.getRequiredById(dto.getId());
userConvert.updateEntity(dto, entity);
this.updateByIdRequired(entity);
String cacheKey = CacheKeys.userDetail(entity.getTenantId(), entity.getId());
applicationEventPublisher.publishEvent(new CacheEvictEvent(cacheKey));
log.info("修改用户成功,用户ID:{}", entity.getId());
}2
3
4
5
6
7
8
9
10
11
12
缓存更新策略建议:
| 策略 | 建议 |
|---|---|
| 读缓存,未命中查库回写 | 常用 |
| 更新数据库后删缓存 | 推荐 |
| 事务提交后删缓存 | 推荐 |
| 直接更新缓存 | 谨慎,只适合简单对象 |
| 延迟双删 | 高并发强一致要求可考虑 |
| 设置 TTL | 必须设置,防止脏缓存永久存在 |
| 缓存 Key 统一管理 | 必须 |
| 缓存清理失败 | 记录日志,必要时补偿任务 |
缓存穿透处理
缓存穿透是指查询一个数据库中不存在的数据,导致每次请求都绕过缓存打到数据库。常见处理方式是缓存空值、参数校验、布隆过滤器。
空值缓存对象。
package io.github.atengk.framework.redis;
import lombok.Getter;
import java.io.Serializable;
/**
* 空值缓存标记
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
public class NullCacheValue implements Serializable {
/**
* 空值标记
*/
private final Boolean nullValue = true;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
带空值缓存的查询示例。
public UserDetailVO getUserDetailWithNullCache(Long userId) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
String cacheKey = CacheKeys.userDetail(tenantId, userId);
Object cachedValue = redisTemplate.opsForValue().get(cacheKey);
if (cachedValue instanceof NullCacheValue) {
return null;
}
if (cachedValue instanceof UserDetailVO userDetailVO) {
return userDetailVO;
}
UserEntity entity = this.lambdaQuery()
.eq(UserEntity::getTenantId, tenantId)
.eq(UserEntity::getId, userId)
.one();
if (entity == null) {
redisTemplate.opsForValue().set(cacheKey, new NullCacheValue(), Duration.ofMinutes(3));
return null;
}
UserDetailVO detailVO = userConvert.toDetailVO(entity);
redisTemplate.opsForValue().set(cacheKey, detailVO, Duration.ofMinutes(30));
return detailVO;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
缓存穿透处理建议:
| 方案 | 适用场景 |
|---|---|
| 参数校验 | 非法 ID、非法编码直接拦截 |
| 空值缓存 | 查询不存在数据 |
| 短 TTL | 空值缓存不要太长 |
| 布隆过滤器 | 超大规模 ID 存在性判断 |
| 接口限流 | 防恶意请求 |
| 权限校验 | 越权请求不要查库 |
缓存击穿处理
缓存击穿是指某个热点 Key 过期瞬间,大量请求同时打到数据库。常见处理方式是互斥锁、逻辑过期、热点 Key 长 TTL。
基于 Redis 简单锁的热点查询示例。
public UserDetailVO getHotUserDetail(Long userId) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
String cacheKey = CacheKeys.userDetail(tenantId, userId);
String lockKey = cacheKey + ":lock";
UserDetailVO cachedValue = redisCacheService.get(cacheKey, UserDetailVO.class);
if (cachedValue != null) {
return cachedValue;
}
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
UserDetailVO secondCheckValue = redisCacheService.get(cacheKey, UserDetailVO.class);
if (secondCheckValue != null) {
return secondCheckValue;
}
UserDetailVO loadedValue = loadUserDetailFromDatabase(tenantId, userId);
if (loadedValue != null) {
redisCacheService.set(cacheKey, loadedValue, Duration.ofMinutes(30));
}
return loadedValue;
} finally {
redisTemplate.delete(lockKey);
}
}
ThreadUtil.sleep(100);
return redisCacheService.get(cacheKey, UserDetailVO.class);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
缓存击穿处理建议:
| 方案 | 适用场景 |
|---|---|
| 互斥锁 | 热点 Key 过期重建 |
| 逻辑过期 | 极高并发热点 |
| 永不过期 + 主动刷新 | 核心热点配置 |
| 后台预热 | 首页配置、热门榜单 |
| 短暂降级 | 拿不到锁时返回旧值或空值 |
| TTL 随机化 | 辅助降低同时过期概率 |
缓存雪崩处理
缓存雪崩是指大量缓存同时失效,或 Redis 故障导致请求全部落到数据库。常见处理方式是 TTL 随机化、缓存预热、多级缓存、限流降级、Redis 高可用。
TTL 随机化工具。
package io.github.atengk.framework.redis;
import cn.hutool.core.util.RandomUtil;
import java.time.Duration;
/**
* 缓存 TTL 工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class CacheTtlUtil {
private CacheTtlUtil() {
}
/**
* 在基础 TTL 上增加随机秒数
*
* @param base 基础过期时间
* @param randomSeconds 最大随机秒数
* @return 随机化后的过期时间
*/
public static Duration randomTtl(Duration base, int randomSeconds) {
int seconds = RandomUtil.randomInt(0, randomSeconds + 1);
return base.plusSeconds(seconds);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
使用示例:
Duration ttl = CacheTtlUtil.randomTtl(Duration.ofMinutes(30), 300);
redisCacheService.set(cacheKey, detailVO, ttl);2
缓存雪崩处理建议:
| 方案 | 说明 |
|---|---|
| TTL 随机化 | 防止大量 Key 同时过期 |
| 缓存预热 | 系统启动或定时预热核心缓存 |
| 多级缓存 | 本地缓存 + Redis |
| 限流降级 | Redis 故障时保护数据库 |
| 熔断保护 | 缓存不可用时快速失败或降级 |
| Redis 高可用 | 主从、哨兵、集群 |
| 监控报警 | 监控命中率、连接数、慢命令 |
缓存一致性设计
缓存一致性是缓存系统最重要的问题。普通业务系统通常采用最终一致策略:写数据库成功后删除缓存,下一次查询重新加载缓存。
一致性设计建议:
| 场景 | 建议 |
|---|---|
| 用户详情 | 修改后删除缓存 |
| 字典数据 | 修改后删除字典缓存 |
| 权限数据 | 角色、菜单变更后删除权限缓存 |
| 数据权限 | 部门、角色变更后删除数据权限缓存 |
| 热点配置 | 更新数据库后删除缓存,并可主动预热 |
| 多实例 | 使用 Redis 或消息广播清理本地缓存 |
| 强一致 | 直接查数据库或使用版本号校验 |
| 失败补偿 | 清理缓存失败时记录日志和补偿任务 |
不要把缓存当成主数据源。数据库仍然是事实来源,缓存只是读取加速层。
消息与异步处理
消息与异步处理用于把耗时操作、通知、日志、报表、缓存刷新、搜索索引同步等逻辑从主流程中拆出来。异步不是为了“更快返回”就随意使用,它必须考虑事务提交时机、上下文传递、幂等消费、异常重试和数据一致性。
异步任务查询数据库
异步任务查询数据库时,不能假设它能读取到外层事务未提交的数据。@Async 会在其他线程执行任务,线程上下文、事务上下文、用户上下文、租户上下文都不会自动完整继承。
异步线程池配置如下。Spring Framework 提供 TaskDecorator 机制用于包装异步任务并辅助上下文传播;Spring 6.1 也提供了 ContextPropagatingTaskDecorator,但它主要用于日志、观测等上下文传播,且会有一定开销。(Home)
package io.github.atengk.framework.async;
import io.github.atengk.framework.context.CompositeContextTaskDecorator;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* 异步线程池配置
*
* @author Ateng
* @since 2026-05-05
*/
@EnableAsync
@Configuration
@RequiredArgsConstructor
public class AsyncConfig {
private final CompositeContextTaskDecorator compositeContextTaskDecorator;
/**
* 业务异步线程池
*
* @return 线程池
*/
@Bean("bizTaskExecutor")
public ThreadPoolTaskExecutor bizTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("biz-task-");
executor.setTaskDecorator(compositeContextTaskDecorator);
executor.initialize();
return executor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
异步任务查询数据库示例。
package io.github.atengk.module.system.user.service.impl;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.service.UserAsyncQueryService;
import io.github.atengk.module.system.user.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* 用户异步查询服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserAsyncQueryServiceImpl implements UserAsyncQueryService {
private final UserService userService;
/**
* 异步查询用户并刷新缓存
*
* @param userId 用户 ID
*/
@Async("bizTaskExecutor")
@Override
public void refreshUserCacheAsync(Long userId) {
UserEntity entity = userService.getById(userId);
if (entity == null) {
log.warn("异步刷新用户缓存失败,用户不存在,用户ID:{}", userId);
return;
}
log.info("异步查询用户成功,用户ID:{},用户名:{}", entity.getId(), entity.getUsername());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
异步查询数据库建议:
| 场景 | 建议 |
|---|---|
| 外层事务未提交 | 不要异步读取该事务刚写入的数据 |
| 必须事务提交后执行 | 使用 @TransactionalEventListener |
| 异步任务异常 | 必须记录日志或任务状态 |
| 查询大数据 | 分页查询,避免占满连接池 |
| 上下文 | 显式传递租户、用户、TraceId |
| 数据源 | 多数据源时显式指定数据源 |
定时任务查询数据库
定时任务没有 Web 请求上下文,因此必须明确设置系统用户、租户上下文和数据范围。多租户系统中,定时任务应区分平台任务和租户任务。
定时任务示例:逐租户统计订单。
package io.github.atengk.module.report.job;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import io.github.atengk.module.report.service.OrderReportService;
import io.github.atengk.module.system.tenant.entity.TenantEntity;
import io.github.atengk.module.system.tenant.service.TenantService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
/**
* 订单报表定时任务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderReportJob {
private final TenantService tenantService;
private final OrderReportService orderReportService;
private final CurrentTenantContext currentTenantContext;
private final CurrentUserContext currentUserContext;
/**
* 每天凌晨统计上一天订单报表
*/
@Scheduled(cron = "0 10 2 * * ?")
public void statisticsYesterdayOrder() {
LocalDate statDate = LocalDate.now().minusDays(1);
for (TenantEntity tenant : tenantService.listEnabledTenants()) {
try {
setSystemContext(tenant.getId());
orderReportService.statisticsOrderDay(tenant.getId(), statDate);
log.info("订单日报统计完成,租户ID:{},统计日期:{}", tenant.getId(), statDate);
} catch (Exception exception) {
log.error("订单日报统计失败,租户ID:{},统计日期:{}", tenant.getId(), statDate, exception);
} finally {
currentTenantContext.clear();
currentUserContext.clear();
}
}
}
/**
* 设置系统上下文
*
* @param tenantId 租户 ID
*/
private void setSystemContext(Long tenantId) {
LoginUser loginUser = new LoginUser();
loginUser.setTenantId(tenantId);
loginUser.setUserId(0L);
loginUser.setUsername("system");
loginUser.setSuperAdmin(true);
currentTenantContext.setTenantId(tenantId);
currentUserContext.setLoginUser(loginUser);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
定时任务建议:
| 场景 | 建议 |
|---|---|
| 平台任务 | 不设置租户,或使用平台上下文 |
| 租户任务 | 遍历租户逐个设置上下文 |
| 数据权限 | 定时任务使用系统用户或专用权限 |
| 异常处理 | 单个租户失败不影响其他租户 |
| 连接池 | 控制并发,避免压垮数据库 |
| 幂等 | 定时统计任务必须可重复执行 |
| 日志 | 必须记录租户 ID、任务日期、处理数量 |
MQ 消费数据库操作
MQ 消费数据库操作时,必须处理重复消费、消费失败重试、事务边界、幂等控制、死信队列和上下文恢复。消费者不能假设消息只会被投递一次。
消费记录表建议如下。
CREATE TABLE sys_mq_consume_record (
id BIGINT NOT NULL COMMENT '主键ID',
message_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消息ID',
topic VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消息主题',
consumer_group VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消费组',
consume_status TINYINT NOT NULL DEFAULT 0 COMMENT '消费状态:0处理中,1成功,2失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
error_message VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '错误信息',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_message_group (message_id, consumer_group)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MQ消费记录表';2
3
4
5
6
7
8
9
10
11
12
13
消费记录实体。
package io.github.atengk.framework.mq.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import io.github.atengk.common.entity.BaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* MQ 消费记录实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_mq_consume_record")
public class MqConsumeRecordEntity extends BaseEntity {
/**
* 消息 ID
*/
private String messageId;
/**
* 消息主题
*/
private String topic;
/**
* 消费组
*/
private String consumerGroup;
/**
* 消费状态:0处理中,1成功,2失败
*/
private Integer consumeStatus;
/**
* 重试次数
*/
private Integer retryCount;
/**
* 错误信息
*/
private String errorMessage;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
MQ 消费处理建议:
| 场景 | 建议 |
|---|---|
| 重复投递 | 使用消息 ID + 消费组唯一约束 |
| 数据库写入 | 放在本地事务中 |
| 消费失败 | 抛异常触发 MQ 重试 |
| 不可恢复失败 | 记录失败并进入死信或人工处理 |
| 幂等业务 | 使用业务单号唯一索引 |
| 租户上下文 | 从消息 Header 或 Body 恢复 |
| 用户上下文 | 一般使用系统用户或消息中携带操作人 |
异步上下文传递
异步上下文包括租户 ID、用户 ID、TraceId、语言环境、数据权限范围等。使用 ThreadLocal 保存上下文时,异步线程不会自动继承调用线程上下文,必须显式复制并清理。
组合上下文任务装饰器如下。
package io.github.atengk.framework.context;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import lombok.RequiredArgsConstructor;
import org.springframework.core.task.TaskDecorator;
import org.springframework.stereotype.Component;
/**
* 组合上下文任务装饰器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
@RequiredArgsConstructor
public class CompositeContextTaskDecorator implements TaskDecorator {
private final CurrentTenantContext currentTenantContext;
private final CurrentUserContext currentUserContext;
/**
* 装饰异步任务,复制并清理上下文
*
* @param runnable 原始任务
* @return 包装后的任务
*/
@Override
public Runnable decorate(Runnable runnable) {
Long tenantId = currentTenantContext.getTenantId();
LoginUser loginUser = currentUserContext.getLoginUser();
return () -> {
try {
currentTenantContext.setTenantId(tenantId);
currentUserContext.setLoginUser(loginUser);
runnable.run();
} finally {
currentTenantContext.clear();
currentUserContext.clear();
}
};
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
上下文传递建议:
| 上下文 | 处理方式 |
|---|---|
| 租户上下文 | 显式复制到异步线程 |
| 用户上下文 | 显式复制,或使用系统用户 |
| TraceId | 复制 MDC |
| 语言环境 | 复制 Locale |
| 数据权限 | 通常根据用户上下文重新计算 |
| 任务结束 | 必须清理 ThreadLocal |
| 线程池复用 | 不清理会串上下文 |
租户上下文传递
租户上下文是多租户系统的底线。异步任务、定时任务、MQ 消费都必须明确租户 ID,否则可能出现跨租户查询、写错租户、数据泄露。
租户上下文对象如下。
package io.github.atengk.framework.tenant;
import cn.hutool.core.util.ObjectUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 当前租户上下文
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class CurrentTenantContext {
private static final ThreadLocal<Long> TENANT_HOLDER = new ThreadLocal<>();
/**
* 设置租户 ID
*
* @param tenantId 租户 ID
*/
public void setTenantId(Long tenantId) {
TENANT_HOLDER.set(tenantId);
}
/**
* 获取租户 ID
*
* @return 租户 ID
*/
public Long getTenantId() {
return TENANT_HOLDER.get();
}
/**
* 获取租户 ID,未设置时返回 0
*
* @return 租户 ID
*/
public Long getTenantIdOrDefault() {
return ObjectUtil.defaultIfNull(getTenantId(), 0L);
}
/**
* 清理租户上下文
*/
public void clear() {
TENANT_HOLDER.remove();
log.trace("租户上下文已清理");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
MQ 消息中携带租户 ID 示例。
package io.github.atengk.framework.mq;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 基础消息对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class BaseMqMessage implements Serializable {
/**
* 消息 ID
*/
private String messageId;
/**
* 租户 ID
*/
private Long tenantId;
/**
* 操作用户 ID
*/
private Long userId;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
用户上下文传递
用户上下文用于审计、自动填充、权限校验和操作日志。异步任务如果是用户主动触发,建议携带用户 ID;如果是系统任务,建议设置系统用户。
用户上下文传递示例。
package io.github.atengk.framework.security;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.ObjectUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 当前用户上下文
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class CurrentUserContext {
private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>();
/**
* 设置登录用户
*
* @param loginUser 登录用户
*/
public void setLoginUser(LoginUser loginUser) {
USER_HOLDER.set(loginUser);
}
/**
* 获取登录用户
*
* @return 登录用户
*/
public LoginUser getLoginUser() {
return USER_HOLDER.get();
}
/**
* 获取登录用户,未设置时返回系统用户
*
* @return 登录用户
*/
public LoginUser getLoginUserOrDefault() {
LoginUser loginUser = getLoginUser();
if (ObjectUtil.isNotNull(loginUser)) {
return loginUser;
}
LoginUser systemUser = new LoginUser();
systemUser.setUserId(0L);
systemUser.setUsername("system");
systemUser.setSuperAdmin(true);
return systemUser;
}
/**
* 获取用户 ID,未设置时返回 0
*
* @return 用户 ID
*/
public Long getUserIdOrDefault() {
return getLoginUserOrDefault().getUserId();
}
/**
* 判断是否超级管理员
*
* @return 是否超级管理员
*/
public boolean isSuperAdmin() {
return BooleanUtil.isTrue(getLoginUserOrDefault().getSuperAdmin());
}
/**
* 清理用户上下文
*/
public void clear() {
USER_HOLDER.remove();
log.trace("用户上下文已清理");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
用户上下文建议:
| 场景 | 建议 |
|---|---|
| Web 请求 | 拦截器设置用户上下文 |
| 异步任务 | 使用 TaskDecorator 复制上下文 |
| 定时任务 | 设置系统用户 |
| MQ 消费 | 从消息中恢复用户 ID 或使用系统用户 |
| 自动填充 | 从当前用户上下文取创建人、更新人 |
| 操作日志 | 记录真实触发用户 |
| 清理 | finally 中清理 |
事务后发送消息
事务后发送消息用于避免“数据库事务回滚了,但消息已经发送出去”的问题。推荐在本地事务中发布 Spring 事件,使用 @TransactionalEventListener(phase = AFTER_COMMIT) 在事务提交后发送 MQ 消息。@TransactionalEventListener 的默认阶段就是 AFTER_COMMIT。(Home)
订单创建事件。
package io.github.atengk.module.order.event;
import lombok.Getter;
/**
* 订单创建事件
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
public class OrderCreatedEvent {
/**
* 订单 ID
*/
private final Long orderId;
/**
* 租户 ID
*/
private final Long tenantId;
/**
* 操作用户 ID
*/
private final Long userId;
public OrderCreatedEvent(Long orderId, Long tenantId, Long userId) {
this.orderId = orderId;
this.tenantId = tenantId;
this.userId = userId;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
业务事务中发布事件。
@Transactional(rollbackFor = Exception.class)
public Long createOrder(OrderCreateDTO dto) {
OrderEntity order = orderConvert.toEntity(dto);
order.setTenantId(currentUserContext.getTenantIdOrDefault());
this.saveRequired(order);
applicationEventPublisher.publishEvent(new OrderCreatedEvent(
order.getId(),
order.getTenantId(),
currentUserContext.getUserIdOrDefault()
));
log.info("创建订单成功,订单ID:{}", order.getId());
return order.getId();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
事务提交后发送消息。
package io.github.atengk.module.order.listener;
import cn.hutool.json.JSONUtil;
import io.github.atengk.module.order.event.OrderCreatedEvent;
import io.github.atengk.framework.mq.MqProducer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
/**
* 订单事件监听器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEventListener {
private final MqProducer mqProducer;
/**
* 订单事务提交后发送订单创建消息
*
* @param event 订单创建事件
*/
@TransactionalEventListener
public void sendOrderCreatedMessage(OrderCreatedEvent event) {
mqProducer.send("order.created", JSONUtil.toJsonStr(event));
log.info("事务提交后发送订单创建消息,订单ID:{}", event.getOrderId());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
事务后发送消息建议:
| 场景 | 建议 |
|---|---|
| 数据库写入后发 MQ | 事务提交后发送 |
| 强可靠消息 | 使用本地消息表或事务消息 |
| 消息发送失败 | 记录本地消息表,定时补偿 |
| 外部 HTTP 通知 | 事务提交后异步调用 |
| 缓存删除 | 事务提交后删除 |
| 搜索索引同步 | 事务提交后发送消息 |
| 邮件短信 | 事务提交后异步发送 |
幂等消费处理
幂等消费用于保证同一条消息重复投递时,业务结果仍然正确。幂等不能只靠“消费者代码判断”,必须有数据库唯一约束或 Redis 原子操作兜底。
消费服务示例。
package io.github.atengk.framework.mq.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.framework.mq.entity.MqConsumeRecordEntity;
import io.github.atengk.framework.mq.mapper.MqConsumeRecordMapper;
import io.github.atengk.framework.mq.service.MqConsumeRecordService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* MQ 消费记录服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
public class MqConsumeRecordServiceImpl
extends ServiceImpl<MqConsumeRecordMapper, MqConsumeRecordEntity>
implements MqConsumeRecordService {
/**
* 尝试开始消费
*
* @param messageId 消息 ID
* @param topic 消息主题
* @param consumerGroup 消费组
* @return 是否允许继续消费
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean tryStartConsume(String messageId, String topic, String consumerGroup) {
MqConsumeRecordEntity record = new MqConsumeRecordEntity();
record.setMessageId(messageId);
record.setTopic(topic);
record.setConsumerGroup(consumerGroup);
record.setConsumeStatus(0);
record.setRetryCount(0);
try {
this.save(record);
return true;
} catch (DuplicateKeyException exception) {
log.warn("消息已被消费或正在消费,消息ID:{},消费组:{}", messageId, consumerGroup);
return false;
}
}
/**
* 标记消费成功
*
* @param messageId 消息 ID
* @param consumerGroup 消费组
*/
@Override
public void markSuccess(String messageId, String consumerGroup) {
this.lambdaUpdate()
.eq(MqConsumeRecordEntity::getMessageId, messageId)
.eq(MqConsumeRecordEntity::getConsumerGroup, consumerGroup)
.set(MqConsumeRecordEntity::getConsumeStatus, 1)
.set(MqConsumeRecordEntity::getErrorMessage, "")
.update();
log.info("消息消费成功,消息ID:{},消费组:{}", messageId, consumerGroup);
}
/**
* 标记消费失败
*
* @param messageId 消息 ID
* @param consumerGroup 消费组
* @param errorMessage 错误信息
*/
@Override
public void markFailed(String messageId, String consumerGroup, String errorMessage) {
this.lambdaUpdate()
.eq(MqConsumeRecordEntity::getMessageId, messageId)
.eq(MqConsumeRecordEntity::getConsumerGroup, consumerGroup)
.set(MqConsumeRecordEntity::getConsumeStatus, 2)
.set(MqConsumeRecordEntity::getErrorMessage, StrUtil.sub(errorMessage, 0, 1000))
.setSql("retry_count = retry_count + 1")
.update();
log.error("消息消费失败,消息ID:{},消费组:{},错误:{}", messageId, consumerGroup, errorMessage);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
订单创建消息消费示例。
package io.github.atengk.module.order.consumer;
import cn.hutool.json.JSONUtil;
import io.github.atengk.framework.mq.service.MqConsumeRecordService;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import io.github.atengk.module.order.event.OrderCreatedEvent;
import io.github.atengk.module.order.service.OrderAfterCreatedService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 订单创建消息消费者
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderCreatedConsumer {
private static final String TOPIC = "order.created";
private static final String CONSUMER_GROUP = "order-service";
private final MqConsumeRecordService mqConsumeRecordService;
private final OrderAfterCreatedService orderAfterCreatedService;
private final CurrentTenantContext currentTenantContext;
private final CurrentUserContext currentUserContext;
/**
* 消费订单创建消息
*
* @param messageId 消息 ID
* @param messageBody 消息体
*/
public void consume(String messageId, String messageBody) {
boolean allowed = mqConsumeRecordService.tryStartConsume(messageId, TOPIC, CONSUMER_GROUP);
if (!allowed) {
return;
}
try {
OrderCreatedEvent event = JSONUtil.toBean(messageBody, OrderCreatedEvent.class);
setContext(event);
orderAfterCreatedService.handleOrderCreated(event.getOrderId());
mqConsumeRecordService.markSuccess(messageId, CONSUMER_GROUP);
} catch (Exception exception) {
mqConsumeRecordService.markFailed(messageId, CONSUMER_GROUP, exception.getMessage());
throw exception;
} finally {
currentTenantContext.clear();
currentUserContext.clear();
}
}
/**
* 设置消费上下文
*
* @param event 订单创建事件
*/
private void setContext(OrderCreatedEvent event) {
LoginUser loginUser = new LoginUser();
loginUser.setTenantId(event.getTenantId());
loginUser.setUserId(event.getUserId());
loginUser.setUsername("mq-consumer");
currentTenantContext.setTenantId(event.getTenantId());
currentUserContext.setLoginUser(loginUser);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
幂等消费建议:
| 场景 | 建议 |
|---|---|
| 消息唯一性 | 使用 messageId + consumerGroup 唯一索引 |
| 业务唯一性 | 使用业务单号唯一索引 |
| 重复消息 | 已成功消费直接忽略 |
| 处理中消息 | 可根据超时时间重试 |
| 消费失败 | 记录失败并抛异常触发 MQ 重试 |
| 不可恢复失败 | 进入死信或人工处理 |
| 消费事务 | 业务操作和消费记录尽量同一事务 |
| 上下文 | 从消息中恢复租户和用户上下文 |
整体建议:缓存用于加速读取,消息用于解耦和削峰,异步用于降低主流程耗时。三者都会引入一致性、上下文和故障恢复问题。凡是涉及数据库写入的异步或消息处理,都必须设计事务提交时机、幂等键、失败重试和补偿机制。
审计与日志
审计与日志用于记录“谁在什么时候对什么数据做了什么操作,以及执行结果如何”。在 Spring Boot 3 + MyBatis-Plus 项目中,审计通常覆盖创建人、更新人、操作日志、SQL 日志、慢 SQL、数据变更日志、敏感操作日志、日志脱敏和 TraceId 链路追踪。
审计日志不是普通调试日志。它要服务于安全追踪、问题排查、合规审查和数据恢复。关键业务操作必须可追踪,敏感数据必须脱敏,日志必须包含 TraceId,数据库变更要能定位操作人和请求来源。
创建人审计
创建人审计用于在新增数据时自动写入 create_by、create_time 等字段。MyBatis-Plus 推荐通过 MetaObjectHandler 实现自动填充,避免每个 Service 手动设置公共字段。
基础实体字段如下。
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 审计基础实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class AuditBaseEntity {
/**
* 创建人 ID
*/
@TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 创建人名称
*/
@TableField(fill = FieldFill.INSERT)
private String createName;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新人 ID
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
* 更新人名称
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateName;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
自动填充处理器如下。
package io.github.atengk.framework.mybatis;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 审计字段自动填充处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AuditMetaObjectHandler implements MetaObjectHandler {
private final CurrentUserContext currentUserContext;
/**
* 新增时自动填充审计字段
*
* @param metaObject 元对象
*/
@Override
public void insertFill(MetaObject metaObject) {
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
LocalDateTime now = LocalDateTime.now();
this.strictInsertFill(metaObject, "createBy", Long.class, loginUser.getUserId());
this.strictInsertFill(metaObject, "createName", String.class, loginUser.getUsername());
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "updateBy", Long.class, loginUser.getUserId());
this.strictInsertFill(metaObject, "updateName", String.class, loginUser.getUsername());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
log.trace("新增审计字段填充完成,用户ID:{},用户名:{}", loginUser.getUserId(), loginUser.getUsername());
}
/**
* 修改时自动填充审计字段
*
* @param metaObject 元对象
*/
@Override
public void updateFill(MetaObject metaObject) {
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
LocalDateTime now = LocalDateTime.now();
this.strictUpdateFill(metaObject, "updateBy", Long.class, loginUser.getUserId());
this.strictUpdateFill(metaObject, "updateName", String.class, loginUser.getUsername());
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, now);
log.trace("修改审计字段填充完成,用户ID:{},用户名:{}", loginUser.getUserId(), loginUser.getUsername());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
创建人审计建议:
| 场景 | 建议 |
|---|---|
| Web 请求 | 从当前登录用户上下文获取 |
| 定时任务 | 使用系统用户 |
| MQ 消费 | 从消息中恢复操作人,或使用系统用户 |
| 数据导入 | 记录导入人 |
| 多租户 | 同时填充 tenant_id |
| 批量新增 | 依赖自动填充,不要逐条手动赋值 |
| 后台脚本 | 明确设置系统用户上下文 |
更新人审计
更新人审计用于记录最后一次修改数据的用户和时间。它适合普通业务表,但不适合作为完整变更历史。完整历史需要数据变更日志单独记录。
修改业务时不需要手动设置 update_by 和 update_time,由自动填充器处理。
@Transactional(rollbackFor = Exception.class)
public void updateUser(UserUpdateDTO dto) {
UserEntity entity = this.getRequiredById(dto.getId());
userConvert.updateEntity(dto, entity);
boolean updated = this.updateById(entity);
if (!updated) {
throw new BusinessException("修改用户失败,请刷新后重试");
}
log.info("修改用户成功,用户ID:{}", dto.getId());
}2
3
4
5
6
7
8
9
10
11
12
更新人审计注意事项:
| 场景 | 建议 |
|---|---|
updateById | 自动填充通常可生效 |
lambdaUpdate().set() | 自动填充不一定覆盖所有字段,建议显式 set 更新时间 |
| XML 自定义 update | 需要手动维护更新时间和更新人 |
| 批量更新相同字段 | 显式 set 更新审计字段 |
| 数据修复脚本 | 使用系统用户或记录脚本操作人 |
| 乐观锁更新失败 | 不应更新审计字段 |
使用 lambdaUpdate 时建议显式设置审计字段,避免不同更新方式造成审计字段不一致。
@Transactional(rollbackFor = Exception.class)
public void disableUser(Long userId) {
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
boolean updated = this.lambdaUpdate()
.eq(UserEntity::getId, userId)
.set(UserEntity::getStatus, 0)
.set(UserEntity::getUpdateBy, loginUser.getUserId())
.set(UserEntity::getUpdateName, loginUser.getUsername())
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("禁用用户失败");
}
log.info("禁用用户成功,用户ID:{},操作人:{}", userId, loginUser.getUsername());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
操作日志
操作日志用于记录用户对业务功能的操作行为,例如新增、修改、删除、导入、导出、审批、授权、禁用、启用等。操作日志通常使用注解 + AOP 实现,避免业务代码中重复写日志记录逻辑。
操作日志表设计如下。
CREATE TABLE sys_operation_log (
id BIGINT NOT NULL COMMENT '日志ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
trace_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '链路追踪ID',
module VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作模块',
operation_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作类型',
description VARCHAR(255) NOT NULL DEFAULT '' COMMENT '操作说明',
request_uri VARCHAR(500) NOT NULL DEFAULT '' COMMENT '请求地址',
request_method VARCHAR(20) NOT NULL DEFAULT '' COMMENT '请求方法',
request_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求IP',
user_agent VARCHAR(1000) NOT NULL DEFAULT '' COMMENT 'User-Agent',
operator_id BIGINT NOT NULL DEFAULT 0 COMMENT '操作人ID',
operator_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称',
request_params TEXT NULL COMMENT '请求参数',
response_result TEXT NULL COMMENT '响应结果',
success_flag TINYINT NOT NULL DEFAULT 1 COMMENT '是否成功:0失败,1成功',
error_message VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '错误信息',
cost_millis BIGINT NOT NULL DEFAULT 0 COMMENT '耗时毫秒',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (id),
KEY idx_tenant_time (tenant_id, create_time),
KEY idx_operator_time (operator_id, create_time),
KEY idx_trace_id (trace_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
操作日志注解如下。
package io.github.atengk.framework.log;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* 操作日志注解
*
* @author Ateng
* @since 2026-05-05
*/
@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface OperationLog {
/**
* 操作模块
*
* @return 操作模块
*/
String module();
/**
* 操作类型
*
* @return 操作类型
*/
String type();
/**
* 操作说明
*
* @return 操作说明
*/
String description() default "";
/**
* 是否记录请求参数
*
* @return 是否记录
*/
boolean recordParams() default true;
/**
* 是否记录响应结果
*
* @return 是否记录
*/
boolean recordResult() default false;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
操作日志切面如下。
package io.github.atengk.framework.log;
import cn.hutool.core.date.StopWatch;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.json.JSONUtil;
import io.github.atengk.framework.log.entity.OperationLogEntity;
import io.github.atengk.framework.log.service.OperationLogService;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import io.github.atengk.framework.trace.TraceIdContext;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* 操作日志切面
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class OperationLogAspect {
private final OperationLogService operationLogService;
private final CurrentUserContext currentUserContext;
/**
* 记录操作日志
*
* @param joinPoint 切点
* @param operationLog 操作日志注解
* @return 方法返回值
* @throws Throwable 业务异常
*/
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
Object result = null;
boolean success = true;
String errorMessage = "";
try {
result = joinPoint.proceed();
return result;
} catch (Throwable throwable) {
success = false;
errorMessage = throwable.getMessage();
throw throwable;
} finally {
stopWatch.stop();
saveLog(joinPoint, operationLog, result, success, errorMessage, stopWatch.getTotalTimeMillis());
}
}
/**
* 保存操作日志
*
* @param joinPoint 切点
* @param operationLog 操作日志注解
* @param result 响应结果
* @param success 是否成功
* @param errorMessage 错误信息
* @param costMillis 耗时毫秒
*/
private void saveLog(ProceedingJoinPoint joinPoint,
OperationLog operationLog,
Object result,
boolean success,
String errorMessage,
long costMillis) {
try {
HttpServletRequest request = getRequest();
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
OperationLogEntity entity = new OperationLogEntity();
entity.setTenantId(loginUser.getTenantId());
entity.setTraceId(TraceIdContext.getTraceId());
entity.setModule(operationLog.module());
entity.setOperationType(operationLog.type());
entity.setDescription(operationLog.description());
entity.setOperatorId(loginUser.getUserId());
entity.setOperatorName(loginUser.getUsername());
entity.setSuccessFlag(success ? 1 : 0);
entity.setErrorMessage(StrUtil.sub(StrUtil.blankToDefault(errorMessage, ""), 0, 1000));
entity.setCostMillis(costMillis);
if (request != null) {
entity.setRequestUri(request.getRequestURI());
entity.setRequestMethod(request.getMethod());
entity.setRequestIp(JakartaServletUtil.getClientIP(request));
entity.setUserAgent(StrUtil.sub(request.getHeader("User-Agent"), 0, 1000));
}
if (operationLog.recordParams()) {
entity.setRequestParams(LogDesensitizeUtil.desensitizeJson(
StrUtil.sub(JSONUtil.toJsonStr(joinPoint.getArgs()), 0, 4000)
));
}
if (operationLog.recordResult()) {
entity.setResponseResult(LogDesensitizeUtil.desensitizeJson(
StrUtil.sub(JSONUtil.toJsonStr(result), 0, 4000)
));
}
operationLogService.saveOperationLog(entity);
} catch (Exception exception) {
log.error("保存操作日志失败", exception);
}
}
/**
* 获取当前请求
*
* @return 当前请求
*/
private HttpServletRequest getRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes == null ? null : attributes.getRequest();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
使用示例:
@OperationLog(module = "用户管理", type = "新增", description = "新增用户")
@PostMapping
public ApiResult<Long> addUser(@RequestBody @Valid UserAddDTO dto) {
return ApiResult.success(userService.addUser(dto));
}2
3
4
5
操作日志建议:
| 操作 | 是否建议记录 |
|---|---|
| 新增 | 建议 |
| 修改 | 建议 |
| 删除 | 必须 |
| 批量删除 | 必须 |
| 导入 | 必须 |
| 导出 | 必须 |
| 授权 | 必须 |
| 审批 | 必须 |
| 登录 | 放登录日志 |
| 普通列表查询 | 通常不记录 |
| 敏感详情查询 | 建议记录 |
SQL 日志
SQL 日志用于开发和测试阶段排查最终 SQL、参数和执行情况。生产环境不建议长期打印完整 SQL,尤其是包含手机号、身份证号、Token、密钥、请求报文等敏感字段的 SQL。
开发环境可以开启 MyBatis 日志。
mybatis-plus:
configuration:
# 开发环境可打印 SQL,生产环境不建议开启
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
# Mapper 包日志级别
io.github.atengk: debug2
3
4
5
6
7
8
9
也可以使用 p6spy 观察 SQL 和耗时。
<!-- p6spy SQL 日志分析,仅建议开发和测试环境使用 -->
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>${p6spy.version}</version>
</dependency>
spring:
datasource:
# 使用 p6spy 代理驱动
driver-class-name: com.p6spy.engine.spy.P6SpyDriver
url: jdbc:p6spy:mysql://127.0.0.1:3306/mybatis_plus_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 1234562
3
4
5
6
7
8
9
10
11
12
13
SQL 日志建议:
| 环境 | 建议 |
|---|---|
| 本地开发 | 可以打印完整 SQL |
| 测试环境 | 按需开启 |
| 预发环境 | 只在排查问题时开启 |
| 生产环境 | 不长期打印完整 SQL |
| 慢 SQL | 单独记录耗时和摘要 |
| 敏感 SQL | 参数脱敏 |
| 批量 SQL | 避免刷屏 |
慢 SQL 日志
慢 SQL 日志用于记录执行时间超过阈值的 SQL,便于定位性能问题。慢 SQL 不应只依赖应用日志,数据库侧慢查询日志、APM、连接池监控也要配合使用。
应用层可以基于 MyBatis 拦截器记录慢 SQL。下面示例只记录 SQL 摘要和耗时,避免生产日志泄露完整参数。
package io.github.atengk.framework.mybatis;
import cn.hutool.core.date.StopWatch;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;
import java.sql.Statement;
import java.util.Properties;
/**
* 慢 SQL 日志拦截器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@Intercepts({
@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, org.apache.ibatis.session.ResultHandler.class}),
@Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),
@Signature(type = StatementHandler.class, method = "batch", args = {Statement.class})
})
public class SlowSqlLogInterceptor implements Interceptor {
private static final long SLOW_SQL_THRESHOLD_MILLIS = 1000L;
/**
* 拦截 SQL 执行并记录慢 SQL
*
* @param invocation 调用对象
* @return 执行结果
* @throws Throwable 执行异常
*/
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
String sql = statementHandler.getBoundSql().getSql();
StopWatch stopWatch = new StopWatch();
stopWatch.start();
try {
return invocation.proceed();
} finally {
stopWatch.stop();
long costMillis = stopWatch.getTotalTimeMillis();
if (costMillis >= SLOW_SQL_THRESHOLD_MILLIS) {
log.warn("发现慢SQL,耗时:{}ms,SQL摘要:{}", costMillis, simplifySql(sql));
}
}
}
/**
* 简化 SQL 文本
*
* @param sql 原始 SQL
* @return 简化 SQL
*/
private String simplifySql(String sql) {
return StrUtil.sub(StrUtil.replace(sql, "\n", " "), 0, 1000);
}
/**
* 设置插件属性
*
* @param properties 属性
*/
@Override
public void setProperties(Properties properties) {
// 当前拦截器暂不需要额外属性
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
慢 SQL 日志建议:
| 项目 | 建议 |
|---|---|
| 阈值 | 后台系统可从 1000ms 开始 |
| 记录内容 | SQL 摘要、耗时、TraceId、Mapper 方法 |
| 参数 | 生产环境谨慎记录 |
| 排查 | 结合 EXPLAIN 和数据库慢查询日志 |
| 归档 | 慢 SQL 日志应可检索 |
| 报警 | 核心接口慢 SQL 应接入监控 |
数据变更日志
数据变更日志用于记录业务数据从旧值到新值的变化。它和操作日志不同:操作日志记录“做了什么操作”,数据变更日志记录“字段具体变成了什么”。
数据变更日志表设计如下。
CREATE TABLE sys_data_change_log (
id BIGINT NOT NULL COMMENT '日志ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
trace_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '链路追踪ID',
table_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '表名',
business_id BIGINT NOT NULL DEFAULT 0 COMMENT '业务ID',
business_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '业务类型',
change_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '变更类型:INSERT、UPDATE、DELETE',
old_value TEXT NULL COMMENT '变更前数据',
new_value TEXT NULL COMMENT '变更后数据',
operator_id BIGINT NOT NULL DEFAULT 0 COMMENT '操作人ID',
operator_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '操作人名称',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
KEY idx_tenant_table_biz (tenant_id, table_name, business_id),
KEY idx_trace_id (trace_id),
KEY idx_operator_time (operator_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据变更日志表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
数据变更日志实体如下。
package io.github.atengk.framework.audit.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import io.github.atengk.common.entity.TenantBaseEntity;
import lombok.Getter;
import lombok.Setter;
/**
* 数据变更日志实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_data_change_log")
public class DataChangeLogEntity extends TenantBaseEntity {
/**
* TraceId
*/
private String traceId;
/**
* 表名
*/
private String tableName;
/**
* 业务 ID
*/
private Long businessId;
/**
* 业务类型
*/
private String businessType;
/**
* 变更类型:INSERT、UPDATE、DELETE
*/
private String changeType;
/**
* 变更前数据
*/
private String oldValue;
/**
* 变更后数据
*/
private String newValue;
/**
* 操作人 ID
*/
private Long operatorId;
/**
* 操作人名称
*/
private String operatorName;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
在 Service 中手动记录关键数据变更更可控。
@Transactional(rollbackFor = Exception.class)
public void updateUserStatus(Long userId, Integer status) {
UserEntity oldEntity = this.getRequiredById(userId);
UserEntity newEntity = new UserEntity();
newEntity.setId(userId);
newEntity.setStatus(status);
boolean updated = this.updateById(newEntity);
if (!updated) {
throw new BusinessException("修改用户状态失败");
}
UserEntity latestEntity = this.getRequiredById(userId);
dataChangeLogService.recordUpdate(
"sys_user",
userId,
"USER",
oldEntity,
latestEntity
);
log.info("修改用户状态成功,用户ID:{},状态:{}", userId, status);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
数据变更日志服务如下。
package io.github.atengk.framework.audit.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.framework.audit.entity.DataChangeLogEntity;
import io.github.atengk.framework.audit.mapper.DataChangeLogMapper;
import io.github.atengk.framework.audit.service.DataChangeLogService;
import io.github.atengk.framework.log.LogDesensitizeUtil;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import io.github.atengk.framework.trace.TraceIdContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 数据变更日志服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataChangeLogServiceImpl
extends ServiceImpl<DataChangeLogMapper, DataChangeLogEntity>
implements DataChangeLogService {
private final CurrentUserContext currentUserContext;
/**
* 记录修改日志
*
* @param tableName 表名
* @param businessId 业务 ID
* @param businessType 业务类型
* @param oldValue 旧值
* @param newValue 新值
*/
@Override
public void recordUpdate(String tableName, Long businessId, String businessType, Object oldValue, Object newValue) {
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
DataChangeLogEntity entity = new DataChangeLogEntity();
entity.setTenantId(loginUser.getTenantId());
entity.setTraceId(TraceIdContext.getTraceId());
entity.setTableName(tableName);
entity.setBusinessId(businessId);
entity.setBusinessType(businessType);
entity.setChangeType("UPDATE");
entity.setOldValue(StrUtil.sub(LogDesensitizeUtil.desensitizeJson(JSONUtil.toJsonStr(oldValue)), 0, 4000));
entity.setNewValue(StrUtil.sub(LogDesensitizeUtil.desensitizeJson(JSONUtil.toJsonStr(newValue)), 0, 4000));
entity.setOperatorId(loginUser.getUserId());
entity.setOperatorName(loginUser.getUsername());
this.save(entity);
log.info("记录数据变更日志成功,表名:{},业务ID:{},类型:{}", tableName, businessId, businessType);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
数据变更日志建议:
| 场景 | 建议 |
|---|---|
| 用户状态变更 | 记录 |
| 角色权限变更 | 必须记录 |
| 订单金额变更 | 必须记录 |
| 审批状态变更 | 必须记录 |
| 库存调整 | 必须记录库存流水 |
| 普通备注修改 | 可按需记录 |
| 大字段变更 | 只记录摘要或差异 |
| 敏感字段 | 脱敏或不记录 |
敏感操作日志
敏感操作日志用于记录具有安全风险或合规要求的行为,例如导出、授权、重置密码、删除数据、查看敏感详情、修改金额、调整库存、审批通过、租户切换等。
敏感操作类型枚举如下。
package io.github.atengk.framework.log;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 敏感操作类型枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum SensitiveOperationTypeEnum {
/**
* 数据导出
*/
EXPORT("EXPORT", "数据导出"),
/**
* 权限授权
*/
GRANT_PERMISSION("GRANT_PERMISSION", "权限授权"),
/**
* 重置密码
*/
RESET_PASSWORD("RESET_PASSWORD", "重置密码"),
/**
* 删除数据
*/
DELETE_DATA("DELETE_DATA", "删除数据"),
/**
* 查看敏感详情
*/
VIEW_SENSITIVE_DETAIL("VIEW_SENSITIVE_DETAIL", "查看敏感详情"),
/**
* 库存调整
*/
STOCK_ADJUST("STOCK_ADJUST", "库存调整");
private final String code;
private final String name;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
敏感操作注解可以复用操作日志,也可以单独定义。
package io.github.atengk.framework.log;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* 敏感操作日志注解
*
* @author Ateng
* @since 2026-05-05
*/
@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface SensitiveOperationLog {
/**
* 操作类型
*
* @return 操作类型
*/
SensitiveOperationTypeEnum type();
/**
* 操作说明
*
* @return 操作说明
*/
String description();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
使用示例:
@SensitiveOperationLog(type = SensitiveOperationTypeEnum.EXPORT, description = "导出用户数据")
@GetMapping("/export")
public void exportUsers(@Valid UserPageQuery query, HttpServletResponse response) {
userExportService.exportUsers(query, response);
}2
3
4
5
敏感操作日志建议:
| 操作 | 记录内容 |
|---|---|
| 数据导出 | 导出人、条件、数量、文件 ID |
| 权限授权 | 授权人、被授权人、角色、权限 |
| 重置密码 | 操作人、目标用户,不记录密码 |
| 删除数据 | 删除人、业务 ID、删除数量 |
| 查看敏感详情 | 查看人、目标数据 ID |
| 修改金额 | 旧金额、新金额、原因 |
| 库存调整 | 调整前、调整后、业务单号 |
| 租户切换 | 原租户、目标租户、操作人 |
日志脱敏
日志脱敏用于防止密码、Token、密钥、身份证号、银行卡号、手机号、邮箱等敏感信息进入日志系统。脱敏应覆盖操作日志、请求参数日志、响应结果日志、异常日志和数据变更日志。
日志脱敏工具如下。
package io.github.atengk.framework.log;
import cn.hutool.core.util.DesensitizedUtil;
import cn.hutool.core.util.ReUtil;
import cn.hutool.core.util.StrUtil;
import java.util.Set;
/**
* 日志脱敏工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class LogDesensitizeUtil {
private static final Set<String> SENSITIVE_KEYS = Set.of(
"password",
"oldPassword",
"newPassword",
"token",
"accessToken",
"refreshToken",
"secret",
"secretKey",
"accessKey",
"authorization"
);
private LogDesensitizeUtil() {
}
/**
* 脱敏 JSON 文本
*
* @param json JSON 文本
* @return 脱敏后文本
*/
public static String desensitizeJson(String json) {
if (StrUtil.isBlank(json)) {
return json;
}
String result = json;
for (String key : SENSITIVE_KEYS) {
result = ReUtil.replaceAll(
result,
"(\"" + key + "\"\\s*:\\s*\")([^\"]*)(\")",
"$1******$3"
);
}
result = maskMobile(result);
result = maskEmail(result);
return result;
}
/**
* 脱敏手机号
*
* @param text 原始文本
* @return 脱敏文本
*/
private static String maskMobile(String text) {
return ReUtil.replaceAll(text, "(1[3-9]\\d)(\\d{4})(\\d{4})", "$1****$3");
}
/**
* 脱敏邮箱
*
* @param text 原始文本
* @return 脱敏文本
*/
private static String maskEmail(String text) {
return ReUtil.replaceAll(text, "([A-Za-z0-9._%+-])([A-Za-z0-9._%+-]*)(@[A-Za-z0-9.-]+)", "$1***$3");
}
/**
* 脱敏手机号字段
*
* @param mobile 手机号
* @return 脱敏手机号
*/
public static String mobile(String mobile) {
return StrUtil.isBlank(mobile) ? mobile : DesensitizedUtil.mobilePhone(mobile);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
日志脱敏建议:
| 数据 | 处理方式 |
|---|---|
| 密码 | 永不记录 |
| Token | 永不记录完整值 |
| SecretKey | 永不记录完整值 |
| 手机号 | 脱敏 |
| 邮箱 | 脱敏 |
| 身份证号 | 脱敏 |
| 银行卡号 | 脱敏 |
| 请求头 Authorization | 过滤或脱敏 |
| 大报文 | 截断 |
| 文件内容 | 不记录 |
日志中最容易泄露的是请求参数和异常对象。不要直接 log.info("请求参数:{}", dto) 记录包含敏感字段的对象,尤其 DTO 上使用了 Lombok @Data 时会自动生成 toString。
TraceId 链路追踪
TraceId 用于把一次请求中的 Controller、Service、Mapper、SQL、异常日志、操作日志串起来。没有 TraceId 时,线上排查通常只能靠时间和用户 ID 模糊检索,效率很低。
TraceId 上下文如下。
package io.github.atengk.framework.trace;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
/**
* TraceId 上下文
*
* @author Ateng
* @since 2026-05-05
*/
public final class TraceIdContext {
private static final ThreadLocal<String> TRACE_ID_HOLDER = new ThreadLocal<>();
private TraceIdContext() {
}
/**
* 设置 TraceId
*
* @param traceId TraceId
*/
public static void setTraceId(String traceId) {
TRACE_ID_HOLDER.set(StrUtil.blankToDefault(traceId, IdUtil.fastSimpleUUID()));
}
/**
* 获取 TraceId
*
* @return TraceId
*/
public static String getTraceId() {
String traceId = TRACE_ID_HOLDER.get();
if (StrUtil.isBlank(traceId)) {
traceId = IdUtil.fastSimpleUUID();
TRACE_ID_HOLDER.set(traceId);
}
return traceId;
}
/**
* 清理 TraceId
*/
public static void clear() {
TRACE_ID_HOLDER.remove();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
Web 请求过滤器中设置 TraceId,并放入 MDC。
package io.github.atengk.framework.trace;
import cn.hutool.core.util.StrUtil;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import java.io.IOException;
/**
* TraceId 过滤器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class TraceIdFilter implements Filter {
public static final String TRACE_ID_HEADER = "X-Trace-Id";
/**
* 处理请求 TraceId
*
* @param request 请求对象
* @param response 响应对象
* @param chain 过滤器链
* @throws IOException IO 异常
* @throws ServletException Servlet 异常
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String traceId = StrUtil.blankToDefault(httpRequest.getHeader(TRACE_ID_HEADER), TraceIdContext.getTraceId());
try {
TraceIdContext.setTraceId(traceId);
MDC.put("traceId", traceId);
httpResponse.setHeader(TRACE_ID_HEADER, traceId);
chain.doFilter(request, response);
} finally {
MDC.remove("traceId");
TraceIdContext.clear();
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
Logback 日志格式加入 TraceId。
<configuration>
<!-- 控制台日志格式,包含 traceId -->
<property name="CONSOLE_LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
异步线程中传递 MDC 和 TraceId。
package io.github.atengk.framework.trace;
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* TraceId 任务装饰器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
public class TraceIdTaskDecorator implements TaskDecorator {
/**
* 包装异步任务并传递 TraceId
*
* @param runnable 原始任务
* @return 包装后的任务
*/
@Override
public Runnable decorate(Runnable runnable) {
String traceId = TraceIdContext.getTraceId();
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
TraceIdContext.setTraceId(traceId);
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
runnable.run();
} finally {
MDC.clear();
TraceIdContext.clear();
}
};
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
TraceId 建议:
| 场景 | 建议 |
|---|---|
| HTTP 请求 | 从请求头读取,没有则生成 |
| HTTP 响应 | 返回 X-Trace-Id |
| 日志格式 | 必须输出 TraceId |
| 操作日志 | 必须保存 TraceId |
| 慢 SQL 日志 | 建议保存 TraceId |
| MQ 消息 | Header 或 Body 中携带 TraceId |
| 异步任务 | TaskDecorator 传递 TraceId |
| 异常响应 | 可返回 TraceId,便于用户反馈问题 |
审计与日志整体规范
审计与日志要覆盖关键链路,但不能变成“所有参数全部落库”。记录越多,泄露风险和存储成本越高。合理做法是按风险分级记录。
整体建议如下:
| 类型 | 重点 |
|---|---|
| 创建人审计 | 自动填充 create_by、create_time |
| 更新人审计 | 自动填充 update_by、update_time |
| 操作日志 | 记录用户行为、接口、结果、耗时 |
| SQL 日志 | 开发测试排查使用 |
| 慢 SQL 日志 | 记录耗时 SQL 摘要和 TraceId |
| 数据变更日志 | 记录关键业务字段变更 |
| 敏感操作日志 | 导出、授权、删除、重置密码必须记录 |
| 日志脱敏 | 密码、Token、手机号、邮箱等脱敏 |
| TraceId | 贯穿请求、日志、操作记录、异常响应 |
生产环境日志建议遵循三个原则:关键行为可追踪,敏感信息不落盘,异常问题可通过 TraceId 快速定位。
测试
测试用于验证 Mapper SQL、Service 业务规则、Controller 接口协议、MyBatis-Plus 插件行为、多租户、多数据源、数据权限和数据库兼容性。Spring Boot 提供 @SpringBootTest 用于加载完整应用上下文,也提供 @WebMvcTest 等切片测试能力;@SpringBootTest 默认不会启动真实服务器,可配合 @AutoConfigureMockMvc 测试 Web 接口,@WebMvcTest 则更适合只测试 MVC 层映射和控制器行为。(Home)
测试分层建议如下:
| 测试类型 | 重点 | 推荐工具 |
|---|---|---|
| Mapper 测试 | SQL、ResultMap、分页、逻辑删除 | @SpringBootTest、真实数据库 |
| Service 测试 | 业务规则、事务、幂等、并发 | @SpringBootTest |
| Controller 测试 | 接口路径、参数校验、响应结构 | MockMvc |
| 插件测试 | 分页、租户、数据权限、乐观锁 | 集成测试 |
| 数据库测试 | SQL 兼容性、索引、约束 | Testcontainers |
| 快速单元测试 | 纯 Java 逻辑、转换器、工具类 | JUnit 5、Mockito |
| 回归测试 | 核心 SQL 结果稳定性 | Testcontainers + SQL 脚本 |
项目建议引入测试依赖:
<!-- Spring Boot 测试基础依赖,包含 JUnit Jupiter、AssertJ、Mockito 等 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Security 测试,如果项目使用 Spring Security -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers JUnit Jupiter 支持 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers MySQL 模块 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<!-- H2 数据库,适合轻量测试,不适合替代真实 MySQL 兼容性测试 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
测试环境配置建议单独放在 application-test.yml。
spring:
profiles:
active: test
mybatis-plus:
configuration:
# 测试环境可开启 SQL 日志,便于排查
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
logging:
level:
io.github.atengk: debug
com.baomidou.mybatisplus: debug2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Mapper 测试
Mapper 测试用于验证 SQL 是否正确、字段映射是否正确、XML 自定义 SQL 是否符合预期、ResultMap 是否完整、逻辑删除和租户条件是否生效。MyBatis-Plus 的 BaseMapper 提供通用 CRUD,适合基础单表操作;自定义 XML、复杂 JOIN、统计报表和分页查询必须重点测试。
Mapper 测试建议使用真实数据库或 Testcontainers。H2 虽然启动快,但语法、函数、索引行为和 MySQL 仍有差异,不建议用 H2 覆盖复杂 SQL。
测试数据脚本示例:
文件位置:src/test/resources/sql/user-test-data.sql
DELETE FROM sys_user;
INSERT INTO sys_user (
id,
tenant_id,
dept_id,
username,
password,
nickname,
phone,
email,
status,
create_by,
create_time,
update_by,
update_time,
deleted,
version
) VALUES
(1001, 1, 10, 'ateng', '******', '阿腾', '13800000001', 'ateng@example.com', 1, 0, NOW(), 0, NOW(), 0, 0),
(1002, 1, 10, 'test01', '******', '测试01', '13800000002', 'test01@example.com', 1, 0, NOW(), 0, NOW(), 0, 0),
(1003, 1, 20, 'disabled', '******', '禁用用户', '13800000003', 'disabled@example.com', 0, 0, NOW(), 0, NOW(), 0, 0),
(1004, 2, 30, 'tenant2', '******', '租户2用户', '13800000004', 'tenant2@example.com', 1, 0, NOW(), 0, NOW(), 0, 0);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Mapper 测试示例:
package io.github.atengk.module.system.user.mapper;
import io.github.atengk.module.system.user.entity.UserEntity;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import java.util.List;
/**
* 用户 Mapper 测试
*
* @author Ateng
* @since 2026-05-05
*/
@SpringBootTest
@ActiveProfiles("test")
@MapperScan("io.github.atengk.**.mapper")
@Sql(scripts = "/sql/user-test-data.sql")
class UserMapperTest {
@Autowired
private UserMapper userMapper;
/**
* 测试根据 ID 查询用户
*/
@Test
void testSelectById() {
UserEntity entity = userMapper.selectById(1001L);
Assertions.assertNotNull(entity);
Assertions.assertEquals("ateng", entity.getUsername());
Assertions.assertEquals(1, entity.getStatus());
}
/**
* 测试查询启用用户列表
*/
@Test
void testSelectEnabledUsers() {
List<UserEntity> users = userMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserEntity>()
.eq(UserEntity::getTenantId, 1L)
.eq(UserEntity::getStatus, 1)
.orderByAsc(UserEntity::getId)
);
Assertions.assertEquals(2, users.size());
Assertions.assertEquals("ateng", users.get(0).getUsername());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
Mapper 测试重点:
| 测试项 | 检查内容 |
|---|---|
| 单表查询 | 查询条件是否正确 |
| XML SQL | 字段、别名、ResultMap 是否正确 |
| JOIN 查询 | 关联字段和过滤条件是否正确 |
| 统计查询 | COUNT、SUM、GROUP BY 是否正确 |
| 逻辑删除 | 已删除数据是否被过滤 |
| 多租户 | 是否只查询当前租户 |
| 数据权限 | 是否追加权限条件 |
| 空结果 | 是否返回空列表而不是异常 |
| 排序 | 排序字段是否稳定 |
Service 测试
Service 测试用于验证业务规则、事务、唯一性校验、状态流转、幂等处理、批量操作和异常分支。Service 测试通常加载完整 Spring 上下文,直接注入 Service。
业务测试应覆盖成功路径和失败路径。只测“新增成功”是不够的,唯一性冲突、数据不存在、无权限、状态不允许、乐观锁失败都应该覆盖。
Service 测试示例:
package io.github.atengk.module.system.user.service;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
/**
* 用户 Service 测试
*
* @author Ateng
* @since 2026-05-05
*/
@SpringBootTest
@ActiveProfiles("test")
@Sql(scripts = "/sql/user-test-data.sql")
class UserServiceTest {
@Autowired
private UserService userService;
@Autowired
private CurrentUserContext currentUserContext;
/**
* 每个测试前设置当前用户上下文
*/
@BeforeEach
void setUp() {
LoginUser loginUser = new LoginUser();
loginUser.setUserId(1L);
loginUser.setTenantId(1L);
loginUser.setDeptId(10L);
loginUser.setUsername("admin");
loginUser.setSuperAdmin(true);
currentUserContext.setLoginUser(loginUser);
}
/**
* 每个测试后清理当前用户上下文
*/
@AfterEach
void tearDown() {
currentUserContext.clear();
}
/**
* 测试新增用户成功
*/
@Test
void testAddUserSuccess() {
UserAddDTO dto = new UserAddDTO();
dto.setUsername("new_user");
dto.setNickname("新用户");
dto.setPhone("13800009999");
dto.setEmail("new_user@example.com");
dto.setStatus(1);
Long userId = userService.addUser(dto);
UserEntity entity = userService.getById(userId);
Assertions.assertNotNull(entity);
Assertions.assertEquals("new_user", entity.getUsername());
Assertions.assertEquals(1L, entity.getTenantId());
}
/**
* 测试用户名重复时新增失败
*/
@Test
void testAddUserDuplicateUsername() {
UserAddDTO dto = new UserAddDTO();
dto.setUsername("ateng");
dto.setNickname("重复用户");
dto.setPhone("13800008888");
dto.setEmail("duplicate@example.com");
dto.setStatus(1);
Assertions.assertThrows(BusinessException.class, () -> userService.addUser(dto));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
Service 测试建议:
| 场景 | 建议 |
|---|---|
| 新增 | 成功、唯一性冲突、参数非法 |
| 修改 | 成功、数据不存在、版本冲突 |
| 删除 | 成功、有引用不能删、无权限 |
| 状态流转 | 允许流转、不允许流转 |
| 批量操作 | 空列表、重复 ID、部分无权限 |
| 事务 | 异常后数据库回滚 |
| 幂等 | 重复请求结果一致 |
| 异步 | 验证任务记录或消息记录 |
Controller 测试
Controller 测试用于验证接口路径、HTTP 方法、请求参数、参数校验、响应结构、异常处理和权限过滤。Spring Boot 的 @WebMvcTest 会自动配置 MVC 基础设施并限制扫描 Controller 相关组件,通常配合 mock 的 Service 使用;如果要加载完整上下文,可使用 @SpringBootTest + @AutoConfigureMockMvc。(Home)
Controller 切片测试示例:
package io.github.atengk.module.system.user.controller;
import cn.hutool.json.JSONUtil;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.service.UserService;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.is;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* 用户 Controller 测试
*
* @author Ateng
* @since 2026-05-05
*/
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
/**
* 测试新增用户接口成功
*
* @throws Exception 测试异常
*/
@Test
void testAddUserSuccess() throws Exception {
UserAddDTO dto = new UserAddDTO();
dto.setUsername("ateng_new");
dto.setNickname("阿腾新用户");
dto.setPhone("13800009999");
dto.setEmail("ateng_new@example.com");
dto.setStatus(1);
Mockito.when(userService.addUser(Mockito.any(UserAddDTO.class))).thenReturn(9001L);
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(JSONUtil.toJsonStr(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success", is(true)))
.andExpect(jsonPath("$.code", is(200)))
.andExpect(jsonPath("$.data", is(9001)));
}
/**
* 测试新增用户参数校验失败
*
* @throws Exception 测试异常
*/
@Test
void testAddUserValidateFailed() throws Exception {
UserAddDTO dto = new UserAddDTO();
dto.setUsername("");
dto.setPhone("invalid");
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(JSONUtil.toJsonStr(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success", is(false)));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
Controller 测试建议:
| 测试项 | 检查内容 |
|---|---|
| 路径 | URL 是否正确 |
| 方法 | GET、POST、PUT、DELETE 是否正确 |
| 入参 | JSON、Query、PathVariable 是否正确 |
| 校验 | 参数非法时响应结构是否统一 |
| 返回 | ApiResult 结构是否一致 |
| 权限 | 无权限时是否拦截 |
| 异常 | 业务异常是否被全局处理 |
| 脱敏 | 敏感字段是否不返回或已脱敏 |
分页插件测试
MyBatis-Plus 分页插件 PaginationInnerInterceptor 支持多种数据库;从 v3.5.9 起,分页插件被拆分,需要单独引入 mybatis-plus-jsqlparser。官方也建议多个插件共存时将分页插件放在最后,并可通过 maxLimit 限制单页数量。(MyBatis-Plus)
分页插件配置:
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
/**
* 测试环境 MyBatis-Plus 分页插件配置
*
* @author Ateng
* @since 2026-05-05
*/
@TestConfiguration
public class TestMyBatisPlusConfig {
/**
* 配置分页插件
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(100L);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
分页测试示例:
package io.github.atengk.module.system.user;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.service.UserService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
/**
* 分页插件测试
*
* @author Ateng
* @since 2026-05-05
*/
@SpringBootTest
@ActiveProfiles("test")
@Sql(scripts = "/sql/user-test-data.sql")
class PaginationPluginTest {
@Autowired
private UserService userService;
/**
* 测试分页查询
*/
@Test
void testPageQuery() {
Page<UserEntity> page = Page.of(1, 2);
Page<UserEntity> result = userService.lambdaQuery()
.eq(UserEntity::getTenantId, 1L)
.orderByAsc(UserEntity::getId)
.page(page);
Assertions.assertEquals(1L, result.getCurrent());
Assertions.assertEquals(2L, result.getSize());
Assertions.assertTrue(result.getTotal() >= 2);
Assertions.assertEquals(2, result.getRecords().size());
}
/**
* 测试不查询总数
*/
@Test
void testPageWithoutCount() {
Page<UserEntity> page = Page.of(1, 2);
page.setSearchCount(false);
Page<UserEntity> result = userService.lambdaQuery()
.eq(UserEntity::getTenantId, 1L)
.orderByAsc(UserEntity::getId)
.page(page);
Assertions.assertEquals(0L, result.getTotal());
Assertions.assertEquals(2, result.getRecords().size());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
分页插件测试重点:
| 测试项 | 检查内容 |
|---|---|
| 普通分页 | records、total、pages 是否正确 |
| 不查询总数 | searchCount=false 是否生效 |
| 最大页大小 | maxLimit 是否生效 |
| 自定义 SQL 分页 | XML SQL 是否被正确分页 |
| 多表分页 | COUNT SQL 是否准确 |
| 排序 | 排序结果是否稳定 |
| 越界页 | 是否返回空列表或按配置处理 |
逻辑删除测试
MyBatis-Plus 逻辑删除会将删除操作转换为更新操作;查询时会自动过滤已删除记录,更新时也会防止更新已逻辑删除的数据。逻辑删除可以通过全局配置或实体字段上的 @TableLogic 实现。(MyBatis-Plus)
逻辑删除实体字段示例:
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import lombok.Getter;
import lombok.Setter;
/**
* 逻辑删除基础字段
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class LogicBaseEntity {
/**
* 逻辑删除标识:0未删除,1已删除
*/
@TableLogic(value = "0", delval = "1")
private Integer deleted;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
逻辑删除测试示例:
package io.github.atengk.module.system.user;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.service.UserService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
/**
* 逻辑删除测试
*
* @author Ateng
* @since 2026-05-05
*/
@SpringBootTest
@ActiveProfiles("test")
@Sql(scripts = "/sql/user-test-data.sql")
class LogicDeleteTest {
@Autowired
private UserService userService;
/**
* 测试逻辑删除后普通查询不可见
*/
@Test
void testLogicDelete() {
boolean removed = userService.removeById(1001L);
Assertions.assertTrue(removed);
UserEntity entity = userService.getById(1001L);
Assertions.assertNull(entity);
}
/**
* 测试逻辑删除后无法普通更新
*/
@Test
void testUpdateAfterLogicDelete() {
userService.removeById(1002L);
UserEntity updateEntity = new UserEntity();
updateEntity.setId(1002L);
updateEntity.setNickname("已删除后修改");
boolean updated = userService.updateById(updateEntity);
Assertions.assertFalse(updated);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
逻辑删除测试重点:
| 测试项 | 检查内容 |
|---|---|
| 删除操作 | 是否转为更新 deleted |
| 查询操作 | 是否自动过滤 deleted=1 |
| 更新操作 | 是否防止更新已删除数据 |
| 自定义 SQL | 是否手动加了 deleted 条件 |
| 唯一约束 | 删除后是否允许重新创建同业务键 |
| 恢复数据 | 是否有明确恢复接口 |
| 物理删除 | 是否只允许特定维护场景 |
乐观锁测试
MyBatis-Plus 乐观锁插件通过 OptimisticLockerInnerInterceptor 和实体字段上的 @Version 实现;更新时会带上旧版本号作为条件,版本不匹配时更新失败。官方说明中,整数类型版本会按 oldVersion + 1 生成新版本,并回写到实体对象。(MyBatis-Plus)
乐观锁配置:
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
/**
* 测试环境乐观锁插件配置
*
* @author Ateng
* @since 2026-05-05
*/
@TestConfiguration
public class TestOptimisticLockerConfig {
/**
* 配置乐观锁插件
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor optimisticLockerInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
乐观锁测试示例:
package io.github.atengk.module.system.user;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.service.UserService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
/**
* 乐观锁测试
*
* @author Ateng
* @since 2026-05-05
*/
@SpringBootTest
@ActiveProfiles("test")
@Sql(scripts = "/sql/user-test-data.sql")
class OptimisticLockerTest {
@Autowired
private UserService userService;
/**
* 测试乐观锁更新成功后版本号递增
*/
@Test
void testUpdateVersionSuccess() {
UserEntity entity = userService.getById(1001L);
Integer oldVersion = entity.getVersion();
entity.setNickname("乐观锁修改成功");
boolean updated = userService.updateById(entity);
Assertions.assertTrue(updated);
Assertions.assertEquals(oldVersion + 1, entity.getVersion());
}
/**
* 测试旧版本更新失败
*/
@Test
void testUpdateVersionConflict() {
UserEntity first = userService.getById(1001L);
UserEntity second = userService.getById(1001L);
first.setNickname("第一次修改");
boolean firstUpdated = userService.updateById(first);
Assertions.assertTrue(firstUpdated);
second.setNickname("第二次旧版本修改");
boolean secondUpdated = userService.updateById(second);
Assertions.assertFalse(secondUpdated);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
乐观锁测试重点:
| 测试项 | 检查内容 |
|---|---|
| 更新成功 | version 是否递增 |
| 并发冲突 | 旧 version 是否更新失败 |
| Entity 回写 | 新 version 是否回写到对象 |
| Wrapper 复用 | 不复用 update wrapper |
| 自定义更新 | 是否满足乐观锁参数条件 |
| 业务提示 | 冲突时提示“数据已被修改” |
多租户测试
MyBatis-Plus 多租户插件 TenantLineInnerInterceptor 通过在 SQL 执行前追加租户条件实现数据隔离;TenantLineHandler 负责提供租户 ID、租户字段名和忽略表规则。(MyBatis-Plus)
多租户测试要覆盖查询隔离、插入租户字段、忽略租户表、跨租户不可见等场景。
租户上下文测试工具:
package io.github.atengk.test;
import io.github.atengk.framework.tenant.CurrentTenantContext;
/**
* 租户测试工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class TenantTestUtil {
private TenantTestUtil() {
}
/**
* 设置测试租户
*
* @param currentTenantContext 租户上下文
* @param tenantId 租户 ID
*/
public static void setTenant(CurrentTenantContext currentTenantContext, Long tenantId) {
currentTenantContext.setTenantId(tenantId);
}
/**
* 清理测试租户
*
* @param currentTenantContext 租户上下文
*/
public static void clearTenant(CurrentTenantContext currentTenantContext) {
currentTenantContext.clear();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
多租户测试示例:
package io.github.atengk.module.system.user;
import io.github.atengk.framework.tenant.CurrentTenantContext;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.service.UserService;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
/**
* 多租户测试
*
* @author Ateng
* @since 2026-05-05
*/
@SpringBootTest
@ActiveProfiles("test")
@Sql(scripts = "/sql/user-test-data.sql")
class TenantPluginTest {
@Autowired
private UserService userService;
@Autowired
private CurrentTenantContext currentTenantContext;
/**
* 测试后清理租户上下文
*/
@AfterEach
void tearDown() {
currentTenantContext.clear();
}
/**
* 测试租户 1 只能看到租户 1 数据
*/
@Test
void testTenantOneIsolation() {
currentTenantContext.setTenantId(1L);
long count = userService.count();
Assertions.assertEquals(3L, count);
}
/**
* 测试租户 2 只能看到租户 2 数据
*/
@Test
void testTenantTwoIsolation() {
currentTenantContext.setTenantId(2L);
long count = userService.count();
Assertions.assertEquals(1L, count);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
多租户测试重点:
| 测试项 | 检查内容 |
|---|---|
| 查询 | 是否自动追加 tenant_id |
| 新增 | 是否自动填充 tenant_id |
| 修改 | 是否不能修改其他租户数据 |
| 删除 | 是否不能删除其他租户数据 |
| 忽略表 | 字典公共表、租户表是否按规则忽略 |
| 自定义 SQL | 是否被租户插件正确解析 |
| 异步任务 | 是否正确传递租户上下文 |
数据权限测试
MyBatis-Plus 的 DataPermissionInterceptor 会在 SQL 执行前动态追加权限相关 SQL 片段,用于限制用户只能访问有权限的数据。(MyBatis-Plus)
数据权限测试要覆盖本人数据、本部门数据、本部门及下级、自定义部门、全部数据和无权限场景。
数据权限测试上下文示例:
package io.github.atengk.test;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import java.util.List;
/**
* 数据权限测试上下文工具
*
* @author Ateng
* @since 2026-05-05
*/
public final class DataScopeTestContext {
private DataScopeTestContext() {
}
/**
* 设置普通用户上下文
*
* @param currentUserContext 当前用户上下文
* @param userId 用户 ID
* @param tenantId 租户 ID
* @param deptId 部门 ID
* @param dataDeptIds 可访问部门列表
*/
public static void setUser(CurrentUserContext currentUserContext,
Long userId,
Long tenantId,
Long deptId,
List<Long> dataDeptIds) {
LoginUser loginUser = new LoginUser();
loginUser.setUserId(userId);
loginUser.setTenantId(tenantId);
loginUser.setDeptId(deptId);
loginUser.setUsername("test-user");
loginUser.setSuperAdmin(false);
loginUser.setDataDeptIds(dataDeptIds);
currentUserContext.setLoginUser(loginUser);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
数据权限测试示例:
package io.github.atengk.module.system.user;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.module.system.user.service.UserService;
import io.github.atengk.test.DataScopeTestContext;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import java.util.List;
/**
* 数据权限测试
*
* @author Ateng
* @since 2026-05-05
*/
@SpringBootTest
@ActiveProfiles("test")
@Sql(scripts = "/sql/user-test-data.sql")
class DataPermissionTest {
@Autowired
private UserService userService;
@Autowired
private CurrentUserContext currentUserContext;
/**
* 测试后清理用户上下文
*/
@AfterEach
void tearDown() {
currentUserContext.clear();
}
/**
* 测试只能查询授权部门数据
*/
@Test
void testDeptDataScope() {
DataScopeTestContext.setUser(currentUserContext, 2001L, 1L, 10L, List.of(10L));
long count = userService.lambdaQuery()
.eq(io.github.atengk.module.system.user.entity.UserEntity::getTenantId, 1L)
.count();
Assertions.assertTrue(count >= 1L);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
数据权限测试重点:
| 测试项 | 检查内容 |
|---|---|
| 本人数据 | 只能查 create_by = 当前用户 |
| 本部门 | 只能查当前部门 |
| 本部门及下级 | 包含子部门 |
| 自定义范围 | 只能查授权部门 |
| 全部数据 | 管理员可查全量 |
| 详情接口 | 无权限 ID 不可访问 |
| 修改接口 | 无权限 ID 不可修改 |
| 导出接口 | 不绕过数据权限 |
多数据源测试
多数据源测试用于验证 @DS 是否生效、主从读写是否正确、报表库是否隔离、事务中数据源是否符合预期。MyBatis-Plus 生态中的 dynamic-datasource 支持数据源分组、读写分离、一主多从等多数据源能力。(MyBatis-Plus)
测试配置示例:
spring:
datasource:
dynamic:
primary: master
strict: true
datasource:
master:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/test_master?serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
report:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/test_report?serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 1234562
3
4
5
6
7
8
9
10
11
12
13
14
15
16
多数据源测试示例:
package io.github.atengk.module.report;
import io.github.atengk.module.report.service.UserReportService;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.service.UserService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* 多数据源测试
*
* @author Ateng
* @since 2026-05-05
*/
@SpringBootTest
@ActiveProfiles("test")
class MultiDataSourceTest {
@Autowired
private UserService userService;
@Autowired
private UserReportService userReportService;
/**
* 测试主库写入
*/
@Test
void testMasterWrite() {
UserEntity entity = new UserEntity();
entity.setTenantId(1L);
entity.setUsername("multi_ds_user");
entity.setNickname("多数据源用户");
entity.setPhone("13800007777");
entity.setStatus(1);
boolean saved = userService.save(entity);
Assertions.assertTrue(saved);
Assertions.assertNotNull(entity.getId());
}
/**
* 测试报表库查询
*/
@Test
void testReportDataSourceQuery() {
long count = userReportService.countUserReport();
Assertions.assertTrue(count >= 0);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
多数据源测试重点:
| 测试项 | 检查内容 |
|---|---|
| 默认数据源 | 不写 @DS 时是否走 master |
| 指定数据源 | @DS("report") 是否生效 |
| 分组数据源 | @DS("slave") 是否进入从库组 |
| 严格模式 | 数据源名称写错是否报错 |
| 事务 | 事务方法内数据源是否一致 |
| 分页插件 | 多数据源分页是否正常 |
| 上下文泄漏 | 测试之间数据源上下文是否清理 |
Testcontainers 数据库测试
Testcontainers 适合做真实数据库集成测试。它可以在测试期间启动临时数据库容器,并在测试结束后销毁。Testcontainers 官方支持通过特殊 JDBC URL 自动启动容器,也支持 JUnit 模式管理容器生命周期;使用 JDBC URL 模式时,只要 Testcontainers 和对应 JDBC 驱动在 classpath 中,就可以将普通 JDBC URL 改成 jdbc:tc: 形式。(Testcontainers Java)
推荐在复杂 SQL、MySQL 函数、窗口函数、唯一索引、事务隔离、分页方言、多租户插件、多数据源等测试中使用 Testcontainers。
Testcontainers 配置示例:
package io.github.atengk.test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.junit.jupiter.Testcontainers;
/**
* Testcontainers 集成测试基础类
*
* @author Ateng
* @since 2026-05-05
*/
@Testcontainers
@SpringBootTest
@ActiveProfiles("testcontainers")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public abstract class BaseTestcontainersTest {
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
application-testcontainers.yml 使用 JDBC URL 方式:
spring:
datasource:
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
url: jdbc:tc:mysql:8.4:///mybatis_plus_test?TC_INITSCRIPT=sql/schema.sql
username: test
password: test
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl2
3
4
5
6
7
8
9
10
也可以使用 @Container + @DynamicPropertySource:
package io.github.atengk.test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.*;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.*;
/**
* MySQL Testcontainers 测试基础类
*
* @author Ateng
* @since 2026-05-05
*/
@Testcontainers
@SpringBootTest
@ActiveProfiles("testcontainers")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public abstract class BaseMySqlContainerTest {
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("mybatis_plus_test")
.withUsername("test")
.withPassword("test")
.withInitScript("sql/schema.sql");
/**
* 动态注册数据源属性
*
* @param registry 动态属性注册器
*/
@DynamicPropertySource
static void registerDataSourceProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("spring.datasource.driver-class-name", MYSQL::getDriverClassName);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
使用示例:
package io.github.atengk.module.system.user;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.service.UserService;
import io.github.atengk.test.BaseMySqlContainerTest;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
/**
* 用户 MySQL 容器集成测试
*
* @author Ateng
* @since 2026-05-05
*/
class UserMySqlContainerTest extends BaseMySqlContainerTest {
@Autowired
private UserService userService;
/**
* 测试真实 MySQL 插入和查询
*/
@Test
void testInsertAndSelectWithRealMysql() {
UserEntity entity = new UserEntity();
entity.setTenantId(1L);
entity.setUsername("container_user");
entity.setNickname("容器用户");
entity.setPhone("13800006666");
entity.setStatus(1);
userService.save(entity);
UserEntity saved = userService.getById(entity.getId());
Assertions.assertNotNull(saved);
Assertions.assertEquals("container_user", saved.getUsername());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Testcontainers 测试建议:
| 场景 | 建议 |
|---|---|
| MySQL 语法 | 使用 Testcontainers |
| 复杂 SQL | 使用 Testcontainers |
| 数据库函数 | 使用 Testcontainers |
| 窗口函数 | 使用 Testcontainers |
| JSON 字段 | 使用 Testcontainers |
| 多租户插件 | 使用 Testcontainers |
| CI 环境 | 需要 Docker 支持 |
| 启动速度 | 可按模块分组,避免过多容器 |
H2 数据库测试
H2 数据库适合快速测试简单 CRUD、Service 逻辑、Repository 基础行为。H2 不适合替代 MySQL 做复杂 SQL、方言、索引、JSON、窗口函数、多租户解析等测试。
H2 测试配置:
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:mybatis_plus_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE
username: sa
password:
sql:
init:
schema-locations: classpath:sql/schema-h2.sql
data-locations: classpath:sql/data-h2.sql
mode: always
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
H2 表结构示例:
DROP TABLE IF EXISTS sys_user;
CREATE TABLE sys_user (
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL DEFAULT 0,
dept_id BIGINT NOT NULL DEFAULT 0,
username VARCHAR(64) NOT NULL DEFAULT '',
password VARCHAR(255) NOT NULL DEFAULT '',
nickname VARCHAR(64) NOT NULL DEFAULT '',
phone VARCHAR(20) NOT NULL DEFAULT '',
email VARCHAR(128) NOT NULL DEFAULT '',
status TINYINT NOT NULL DEFAULT 1,
create_by BIGINT NOT NULL DEFAULT 0,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by BIGINT NOT NULL DEFAULT 0,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
H2 测试示例:
package io.github.atengk.module.system.user;
import io.github.atengk.module.system.user.service.UserService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* H2 数据库测试
*
* @author Ateng
* @since 2026-05-05
*/
@SpringBootTest
@ActiveProfiles("h2")
class H2DatabaseTest {
@Autowired
private UserService userService;
/**
* 测试 H2 环境基础查询
*/
@Test
void testCount() {
long count = userService.count();
Assertions.assertTrue(count >= 0);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
H2 使用建议:
| 场景 | 是否推荐 |
|---|---|
| 简单 CRUD | 推荐 |
| Service 基础逻辑 | 推荐 |
| 参数校验 | 推荐 |
| MySQL 专有函数 | 不推荐 |
| JSON 字段 | 不推荐 |
| 复杂 JOIN | 谨慎 |
| 窗口函数 | 谨慎 |
| 分页方言 | 更推荐 Testcontainers |
| 数据权限插件 | 更推荐真实数据库 |
SQL 回归测试
SQL 回归测试用于确保核心 SQL 在后续改动中结果不变。适合复杂报表、权限查询、多表 JOIN、统计 SQL、分页 SQL、数据权限 SQL。每次修改 Mapper XML、索引、插件配置或表结构后,都应运行 SQL 回归测试。
SQL 回归测试建议准备三类脚本:
src/test/resources/sql
├── schema.sql
├── regression-data.sql
└── cleanup.sql2
3
4
回归数据示例:
DELETE FROM biz_order;
DELETE FROM biz_order_item;
INSERT INTO biz_order (
id,
tenant_id,
order_no,
user_id,
order_status,
total_amount,
pay_amount,
create_time,
deleted,
version
) VALUES
(2001, 1, 'ORDER_001', 1001, 20, 100.00, 100.00, '2026-05-01 10:00:00', 0, 0),
(2002, 1, 'ORDER_002', 1001, 20, 200.00, 200.00, '2026-05-02 10:00:00', 0, 0),
(2003, 1, 'ORDER_003', 1002, 90, 300.00, 0.00, '2026-05-03 10:00:00', 0, 0),
(2004, 2, 'ORDER_004', 1004, 20, 400.00, 400.00, '2026-05-04 10:00:00', 0, 0);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
统计 Mapper:
package io.github.atengk.module.report.mapper;
import io.github.atengk.module.report.vo.OrderAmountStatVO;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
/**
* 订单报表 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface OrderReportMapper {
/**
* 查询订单金额统计
*
* @param tenantId 租户 ID
* @param startDate 开始日期
* @param endDate 结束日期
* @return 金额统计列表
*/
List<OrderAmountStatVO> selectOrderAmountStats(@Param("tenantId") Long tenantId,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
SQL 回归测试:
package io.github.atengk.module.report;
import io.github.atengk.module.report.mapper.OrderReportMapper;
import io.github.atengk.module.report.vo.OrderAmountStatVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* 订单报表 SQL 回归测试
*
* @author Ateng
* @since 2026-05-05
*/
@SpringBootTest
@ActiveProfiles("testcontainers")
@MapperScan("io.github.atengk.**.mapper")
@Sql(scripts = "/sql/regression-data.sql")
class OrderReportSqlRegressionTest {
@Autowired
private OrderReportMapper orderReportMapper;
/**
* 测试订单支付金额统计结果稳定
*/
@Test
void testOrderAmountStats() {
List<OrderAmountStatVO> stats = orderReportMapper.selectOrderAmountStats(
1L,
LocalDate.of(2026, 5, 1),
LocalDate.of(2026, 5, 31)
);
Assertions.assertEquals(1, stats.size());
OrderAmountStatVO stat = stats.get(0);
Assertions.assertEquals(1L, stat.getTenantId());
Assertions.assertEquals(0, new BigDecimal("300.00").compareTo(stat.getPayAmount()));
Assertions.assertEquals(2L, stat.getPayOrderCount());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
统计 VO:
package io.github.atengk.module.report.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 订单金额统计返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class OrderAmountStatVO {
/**
* 租户 ID
*/
private Long tenantId;
/**
* 支付订单数量
*/
private Long payOrderCount;
/**
* 支付金额
*/
private BigDecimal payAmount;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
SQL 回归测试重点:
| 场景 | 建议 |
|---|---|
| 复杂报表 SQL | 必须写回归测试 |
| 多表 JOIN | 验证关联结果 |
| 数据权限 SQL | 验证不同用户结果 |
| 多租户 SQL | 验证租户隔离 |
| 逻辑删除 | 验证 deleted 过滤 |
| 分页 SQL | 验证 total 和 records |
| 排序 SQL | 验证顺序稳定 |
| 聚合 SQL | 验证统计值精确 |
| 索引优化后 | 重新运行回归测试 |
测试整体规范
测试不应只追求覆盖率数字。更重要的是覆盖关键业务规则、插件行为、数据库行为和回归风险。
整体规范如下:
| 规范项 | 建议 |
|---|---|
| 测试命名 | test业务场景_预期结果 或清晰英文方法名 |
| 测试数据 | 使用独立 SQL 脚本 |
| 测试环境 | 使用 test、h2、testcontainers profile |
| 数据隔离 | 每个测试类准备自己的数据 |
| 上下文清理 | 清理租户、用户、TraceId 等 ThreadLocal |
| Mapper 测试 | 重点验证 SQL 和映射 |
| Service 测试 | 重点验证业务规则和事务 |
| Controller 测试 | 重点验证协议和参数校验 |
| 插件测试 | 覆盖分页、租户、权限、逻辑删除、乐观锁 |
| 数据库兼容 | 复杂 SQL 使用 Testcontainers |
| H2 使用 | 只用于轻量测试 |
| CI 执行 | 快速测试和容器测试可分阶段运行 |
推荐测试分层执行:
单元测试:工具类、枚举、转换器、参数清洗
集成测试:Mapper、Service、事务、插件
接口测试:Controller、参数校验、统一响应
数据库测试:Testcontainers、SQL 回归、复杂报表2
3
4
测试的核心目标是让开发者敢于重构。只要 Mapper XML、Service 业务规则、插件配置、权限逻辑和复杂 SQL 都有测试兜底,Spring Boot 3 + MyBatis-Plus 项目的迭代风险会明显降低。
数据库迁移
数据库迁移用于把表结构、索引、初始化数据、灰度变更脚本纳入版本管理,避免不同环境表结构不一致。Spring Boot 支持 Flyway 和 Liquibase 两类高级数据库迁移工具;如果项目使用 Flyway 或 Liquibase,就不建议同时依赖 schema.sql、data.sql 做正式库初始化,避免多套初始化机制互相干扰。Spring Boot 文档也明确建议高阶迁移工具应单独承担 schema 创建和初始化职责。(Home)
Flyway 集成
Flyway 适合以 SQL 脚本为主的数据库版本管理。Spring Boot 启动时可以自动执行 Flyway migration;默认脚本目录为 classpath:db/migration,脚本命名通常为 V<版本号>__<说明>.sql,例如 V1__create_sys_user.sql。MySQL 等数据库需要额外引入对应数据库模块,例如 flyway-mysql。(Home)
文件位置:pom.xml
<!-- Flyway 核心能力,用于数据库版本迁移 -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Flyway MySQL 支持,MySQL 项目需要引入 -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>2
3
4
5
6
7
8
9
10
11
文件位置:src/main/resources/application.yml
spring:
flyway:
# 是否启用 Flyway
enabled: true
# 迁移脚本位置
locations: classpath:db/migration
# 迁移历史表
table: flyway_schema_history
# 非空库首次接入时可开启基线
baseline-on-migrate: true
# 基线版本
baseline-version: 0
# 启动时校验脚本校验和
validate-on-migrate: true
# 禁止生产环境 clean,避免误删库
clean-disabled: true2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
推荐脚本结构:
src/main/resources/db/migration
├── V1__create_system_tables.sql
├── V2__create_business_tables.sql
├── V3__add_user_indexes.sql
├── V4__init_dict_data.sql
└── R__refresh_views.sql2
3
4
5
6
初始化用户表脚本示例:
-- 创建用户表
CREATE TABLE sys_user (
id BIGINT NOT NULL COMMENT '用户ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
dept_id BIGINT NOT NULL DEFAULT 0 COMMENT '部门ID',
username VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名',
password VARCHAR(255) NOT NULL DEFAULT '' COMMENT '密码',
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
phone VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_username (tenant_id, username),
KEY idx_tenant_dept (tenant_id, dept_id),
KEY idx_tenant_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Flyway 使用建议:
| 场景 | 建议 |
|---|---|
| 表结构变更 | 使用 V版本__说明.sql |
| 视图、函数刷新 | 使用 R__说明.sql |
| 初始化字典 | 可以使用版本化脚本 |
| 测试数据 | 放到 src/test/resources/db/migration |
| 生产环境 | 禁止 clean |
| 已有数据库接入 | 使用 baseline-on-migrate |
| 脚本修改 | 已执行脚本不要修改,新增脚本修正 |
Liquibase 集成
Liquibase 适合需要更强变更元数据、回滚定义、上下文、标签和多格式 changelog 的团队。Liquibase 的核心单元是 changeset,每个 changeset 由 author + id + changelog file path 唯一标识;Liquibase 支持 SQL、XML、YAML、JSON 等 changelog 格式。
文件位置:pom.xml
<!-- Liquibase 数据库迁移工具 -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>2
3
4
5
文件位置:src/main/resources/application.yml
spring:
liquibase:
# 是否启用 Liquibase
enabled: true
# 主 changelog 文件
change-log: classpath:db/changelog/db.changelog-master.yaml
# 迁移历史表
database-change-log-table: databasechangelog
# 迁移锁表
database-change-log-lock-table: databasechangeloglock
# 按环境控制 changeset
contexts: prod2
3
4
5
6
7
8
9
10
11
12
YAML changelog 示例:
文件位置:src/main/resources/db/changelog/db.changelog-master.yaml
databaseChangeLog:
# 用户表结构
- include:
file: db/changelog/changes/001-create-sys-user.yaml
# 初始化字典数据
- include:
file: db/changelog/changes/002-init-dict-data.yaml2
3
4
5
6
7
8
文件位置:src/main/resources/db/changelog/changes/001-create-sys-user.yaml
databaseChangeLog:
- changeSet:
id: 001-create-sys-user
author: Ateng
comments: 创建系统用户表
changes:
- createTable:
tableName: sys_user
remarks: 系统用户表
columns:
- column:
name: id
type: BIGINT
remarks: 用户ID
constraints:
primaryKey: true
nullable: false
- column:
name: tenant_id
type: BIGINT
defaultValueNumeric: 0
remarks: 租户ID
constraints:
nullable: false
- column:
name: username
type: VARCHAR(64)
defaultValue: ''
remarks: 用户名
constraints:
nullable: false
- column:
name: create_time
type: DATETIME
defaultValueComputed: CURRENT_TIMESTAMP
remarks: 创建时间
constraints:
nullable: false
rollback:
- dropTable:
tableName: sys_user2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Liquibase 回滚要谨慎设计。Liquibase 的普通 rollback 是按 tag 顺序回退到指定标签;对于不能自动回滚的变更,需要在 changelog 中显式定义 rollback。Liquibase 官方文档也说明,formatted SQL changeset 不支持自动 rollback,需要自定义 rollback。(Liquibase Docs)
Liquibase 使用建议:
| 场景 | 建议 |
|---|---|
| 多团队协作 | 使用 Liquibase changeset |
| 需要 rollback | 每个关键 changeset 定义 rollback |
| 多环境脚本 | 使用 contexts 和 labels |
| SQL 偏好团队 | 使用 formatted SQL |
| 结构化变更 | 使用 YAML/XML |
| 审计要求高 | Liquibase 更便于追踪变更元数据 |
初始化脚本管理
初始化脚本包括表结构、索引、基础字典、系统参数、默认角色、默认菜单等。正式项目不建议把所有初始化内容堆到一个大 SQL 文件中,应按模块和版本拆分。
推荐 Flyway 目录:
src/main/resources/db/migration
├── V1__create_base_tables.sql
├── V2__create_permission_tables.sql
├── V3__create_business_tables.sql
├── V4__init_system_dict.sql
├── V5__init_system_menu.sql
└── V6__add_user_unique_indexes.sql2
3
4
5
6
7
推荐 Liquibase 目录:
src/main/resources/db/changelog
├── db.changelog-master.yaml
└── changes
├── 001-create-base-tables.yaml
├── 002-create-permission-tables.yaml
├── 003-create-business-tables.yaml
├── 004-init-system-dict.yaml
└── 005-init-system-menu.yaml2
3
4
5
6
7
8
初始化脚本建议:
| 类型 | 建议 |
|---|---|
| 建表脚本 | 独立版本脚本 |
| 索引脚本 | 可随表创建,也可独立脚本 |
| 字典数据 | 独立脚本,支持重复执行时要谨慎 |
| 菜单数据 | 建议使用稳定 ID |
| 角色权限 | 建议按环境区分 |
| 测试数据 | 不放正式迁移目录 |
| 大量历史数据 | 不建议放启动迁移中执行 |
表结构版本管理
表结构版本管理要求所有 DDL 变更都进入迁移脚本,禁止直接在线上库手工改表。变更脚本应和代码同仓库、同分支、同评审、同发布。
版本命名建议:
| 类型 | Flyway 示例 | 说明 |
|---|---|---|
| 建表 | V1__create_sys_user.sql | 创建表 |
| 加字段 | V2__add_user_email.sql | 新增字段 |
| 加索引 | V3__add_user_phone_index.sql | 新增索引 |
| 改字段 | V4__modify_user_remark_length.sql | 修改字段 |
| 初始化数据 | V5__init_user_status_dict.sql | 初始化字典 |
| 视图刷新 | R__refresh_order_report_view.sql | 可重复脚本 |
表结构变更规范:
| 变更类型 | 建议 |
|---|---|
| 新增字段 | 尽量先允许空或设置默认值 |
| 删除字段 | 先废弃代码,再灰度删除字段 |
| 修改字段类型 | 评估锁表和数据兼容 |
| 新增索引 | 大表使用在线 DDL 能力 |
| 删除索引 | 确认没有查询依赖 |
| 重命名字段 | 生产环境谨慎,优先新增字段迁移数据 |
| 大表变更 | 需要灰度方案和回滚方案 |
测试数据管理
测试数据用于单元测试、集成测试、开发环境初始化,不应进入生产迁移脚本。Spring Boot 文档提到,Flyway 测试专用迁移可以放到 src/test/resources/db/migration,这样只在测试启动时参与迁移。Liquibase 可通过 contexts 控制测试数据 changeset。(Home)
推荐结构:
src/test/resources/db/migration
├── V9991__test_user_data.sql
├── V9992__test_order_data.sql
└── V9993__test_permission_data.sql2
3
4
测试数据脚本示例:
-- 测试用户数据,仅用于测试环境
INSERT INTO sys_user (
id,
tenant_id,
dept_id,
username,
password,
nickname,
phone,
status,
create_by,
create_time,
update_by,
update_time,
deleted,
version
) VALUES
(1001, 1, 10, 'ateng', '******', '阿腾', '13800000001', 1, 0, NOW(), 0, NOW(), 0, 0);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
测试数据建议:
| 场景 | 建议 |
|---|---|
| 单元测试 | 使用独立 SQL |
| 集成测试 | 使用 Testcontainers + 初始化脚本 |
| 开发环境 | 可以有 dev 专用数据 |
| 生产环境 | 禁止混入测试数据 |
| 用户密码 | 使用固定测试哈希,不写明文 |
| 数据 ID | 使用固定 ID,便于断言 |
灰度发布脚本
灰度发布脚本用于支持代码和数据库变更分阶段上线。数据库变更要遵循“向前兼容”原则:新代码可以运行在旧数据上,旧代码也尽量能运行在新结构上。
常见灰度流程:
第一步:新增字段,允许为空,旧代码不使用
第二步:上线兼容新字段的新代码
第三步:后台任务补齐历史数据
第四步:切换读取新字段
第五步:确认稳定后删除旧字段或旧逻辑2
3
4
5
灰度加字段示例:
-- 第一步:新增字段,允许为空,避免大表直接设置非空造成风险
ALTER TABLE sys_user
ADD COLUMN email VARCHAR(128) NULL COMMENT '邮箱';
-- 第二步:新增普通索引,是否在线执行取决于数据库版本和运维规范
CREATE INDEX idx_tenant_email ON sys_user (tenant_id, email);2
3
4
5
6
历史数据补齐建议用任务执行,不建议在启动迁移中一次性更新超大表:
-- 小表可以直接补齐,大表不建议这样直接执行
UPDATE sys_user
SET email = ''
WHERE email IS NULL;2
3
4
灰度脚本建议:
| 场景 | 建议 |
|---|---|
| 新增字段 | 先允许空 |
| 字段改名 | 新增字段 + 双写 + 迁移 + 切读 |
| 删除字段 | 先停用代码,再删除字段 |
| 大表更新 | 分批任务,不在启动迁移中长事务执行 |
| 新增索引 | 评估在线 DDL |
| 枚举值扩展 | 先兼容未知值 |
| 回滚代码 | 数据库结构尽量兼容旧代码 |
回滚脚本
回滚脚本用于在发布失败时恢复数据库结构或数据。Flyway Teams 提供 undo migration,undo 脚本默认以 U 开头并与 versioned migration 对应;但它属于 Teams 能力,普通项目更常见做法是准备人工审核的回滚 SQL。(文档中心)
回滚脚本目录建议:
db
├── migration
│ ├── V10__add_user_email.sql
│ └── V11__add_order_status_index.sql
└── rollback
├── V10__rollback_add_user_email.sql
└── V11__rollback_add_order_status_index.sql2
3
4
5
6
7
回滚脚本示例:
-- 回滚 V10__add_user_email.sql
-- 注意:删除字段会造成数据丢失,执行前必须确认已经备份
ALTER TABLE sys_user
DROP COLUMN email;2
3
4
回滚脚本建议:
| 变更类型 | 回滚策略 |
|---|---|
| 新增字段 | 可以 drop,但会丢数据 |
| 新增索引 | 可以 drop index |
| 删除字段 | 无法直接恢复,必须备份 |
| 修改字段类型 | 需要评估数据是否可逆 |
| 数据修复 | 必须保留修复前快照 |
| 初始化数据 | 可以 delete,但要避免误删用户数据 |
| 大表变更 | 优先用向前修复,不轻易回滚结构 |
生产发布更推荐“前滚修复”而不是盲目回滚数据库。数据库回滚一旦涉及数据丢失,风险通常高于代码回滚。
多环境脚本差异
多环境脚本差异包括 dev、test、stage、prod 的初始化数据、连接配置、测试账号、灰度开关等差异。结构迁移脚本应尽量保持一致,环境差异主要放在配置和数据初始化上。
Flyway 多环境配置示例:
spring:
flyway:
# 正式迁移脚本
locations: classpath:db/migration
---
spring:
config:
activate:
on-profile: dev
flyway:
# 开发环境额外执行 dev 数据
locations: classpath:db/migration,classpath:db/dev-migration2
3
4
5
6
7
8
9
10
11
12
Liquibase 多环境 contexts 示例:
spring:
liquibase:
contexts: prod
---
spring:
config:
activate:
on-profile: test
liquibase:
contexts: test2
3
4
5
6
7
8
9
10
多环境脚本建议:
| 类型 | 建议 |
|---|---|
| 表结构 | 各环境保持一致 |
| 基础字典 | 各环境保持一致 |
| 测试账号 | 只放 dev/test |
| Mock 数据 | 只放 dev/test |
| 压测数据 | 独立脚本,不能进 prod |
| 灰度配置 | 通过配置中心或参数表控制 |
| 生产特殊修复 | 单独脚本、单独审批 |
脚本执行审计
脚本执行审计用于记录谁提交了脚本、谁审核了脚本、在哪个环境执行、执行结果如何。Flyway 和 Liquibase 自身都有迁移历史表,但项目层面仍建议建立发布审计机制。
迁移审计表设计:
CREATE TABLE sys_db_migration_audit (
id BIGINT NOT NULL COMMENT '审计ID',
migration_tool VARCHAR(32) NOT NULL DEFAULT '' COMMENT '迁移工具:flyway、liquibase',
script_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '脚本版本',
script_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '脚本名称',
environment VARCHAR(32) NOT NULL DEFAULT '' COMMENT '执行环境',
executor VARCHAR(64) NOT NULL DEFAULT '' COMMENT '执行人',
execute_status TINYINT NOT NULL DEFAULT 0 COMMENT '执行状态:0执行中,1成功,2失败',
error_message VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '错误信息',
start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间',
end_time DATETIME NULL COMMENT '结束时间',
PRIMARY KEY (id),
KEY idx_env_time (environment, start_time),
KEY idx_script_name (script_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库迁移审计表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
脚本执行审计建议:
| 审计项 | 建议 |
|---|---|
| 脚本版本 | 记录 |
| 脚本名称 | 记录 |
| 执行环境 | 记录 |
| 执行人 | 记录 |
| 执行时间 | 记录 |
| 执行结果 | 记录 |
| 错误信息 | 截断保存 |
| 审批单号 | 有发布流程时记录 |
| 回滚脚本 | 与变更脚本关联 |
监控与可观测性
监控与可观测性用于回答三个问题:系统是否正常、哪里变慢了、为什么失败了。Spring Boot Actuator 提供 health、metrics、prometheus、loggers、mappings、liquibase 等端点;其中 /actuator/prometheus 需要引入 micrometer-registry-prometheus,且默认只有 health 端点暴露,需要显式配置暴露范围。(Home)
数据库连接池监控
数据库连接池监控用于观察连接池是否耗尽、连接是否泄漏、活跃连接是否过高、等待连接是否导致接口变慢。Spring Boot Actuator 会自动为 DataSource 暴露 jdbc.connections 前缀的指标,Hikari 还会暴露 hikaricp 前缀指标。(Home)
文件位置:pom.xml
<!-- Spring Boot Actuator 监控端点 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Prometheus 指标导出 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>2
3
4
5
6
7
8
9
10
11
文件位置:src/main/resources/application.yml
management:
endpoints:
web:
exposure:
# 生产环境不要暴露过多端点,建议通过网关、内网或鉴权保护
include: health,info,metrics,prometheus
endpoint:
health:
# 生产环境建议 when-authorized,不建议公网 always
show-details: when-authorized
metrics:
tags:
# 应用名称标签,便于 Prometheus 聚合
application: mybatis-plus-demo
spring:
datasource:
hikari:
# 连接池名称会作为 Hikari 指标标签
pool-name: MyBatisPlusHikariPool
maximum-pool-size: 30
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 600002
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
常用连接池指标:
| 指标 | 说明 |
|---|---|
jdbc.connections.active | 活跃连接数 |
jdbc.connections.idle | 空闲连接数 |
jdbc.connections.max | 最大连接数 |
jdbc.connections.min | 最小连接数 |
hikaricp.connections.active | Hikari 活跃连接 |
hikaricp.connections.idle | Hikari 空闲连接 |
hikaricp.connections.timeout | 获取连接超时次数 |
hikaricp.connections.pending | 等待连接线程数 |
连接池告警建议:
| 告警项 | 建议阈值 |
|---|---|
| 活跃连接占比 | 超过 80% 持续 5 分钟 |
| pending 连接 | 大于 0 持续 1 分钟 |
| 连接超时 | 5 分钟内出现增长 |
| 空闲连接为 0 | 持续 5 分钟 |
| 接口耗时升高 | 与连接池 pending 联动分析 |
SQL 执行耗时监控
SQL 执行耗时监控用于统计 Mapper 方法或 SQL 类型的耗时分布。可以通过 MyBatis 拦截器 + Micrometer Timer 记录执行耗时。
文件位置:src/main/java/io/github/atengk/framework/mybatis/SqlMetricsInterceptor.java
package io.github.atengk.framework.mybatis;
import cn.hutool.core.date.StopWatch;
import cn.hutool.core.util.StrUtil;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.springframework.stereotype.Component;
import java.sql.Statement;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
/**
* SQL 执行耗时指标拦截器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
@Intercepts({
@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, org.apache.ibatis.session.ResultHandler.class}),
@Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),
@Signature(type = StatementHandler.class, method = "batch", args = {Statement.class})
})
public class SqlMetricsInterceptor implements Interceptor {
private final MeterRegistry meterRegistry;
/**
* 拦截 SQL 执行并记录耗时指标
*
* @param invocation 调用对象
* @return 执行结果
* @throws Throwable 执行异常
*/
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
String mappedStatementId = getMappedStatementId(statementHandler);
StopWatch stopWatch = new StopWatch();
stopWatch.start();
try {
return invocation.proceed();
} finally {
stopWatch.stop();
long costMillis = stopWatch.getTotalTimeMillis();
meterRegistry.timer(
"mybatis.sql.execution",
"mapper", mappedStatementId,
"type", getSqlType(statementHandler.getBoundSql().getSql())
).record(costMillis, TimeUnit.MILLISECONDS);
if (costMillis >= 1000) {
log.warn("SQL执行较慢,耗时:{}ms,Mapper:{}", costMillis, mappedStatementId);
}
}
}
/**
* 获取 Mapper 方法 ID
*
* @param statementHandler StatementHandler
* @return Mapper 方法 ID
*/
private String getMappedStatementId(StatementHandler statementHandler) {
try {
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
return mappedStatement.getId();
} catch (Exception exception) {
log.debug("获取MappedStatement失败", exception);
return "unknown";
}
}
/**
* 获取 SQL 类型
*
* @param sql SQL 文本
* @return SQL 类型
*/
private String getSqlType(String sql) {
String trimSql = StrUtil.trim(sql).toLowerCase();
if (StrUtil.startWith(trimSql, "select")) {
return "select";
}
if (StrUtil.startWith(trimSql, "insert")) {
return "insert";
}
if (StrUtil.startWith(trimSql, "update")) {
return "update";
}
if (StrUtil.startWith(trimSql, "delete")) {
return "delete";
}
return "other";
}
/**
* 设置插件属性
*
* @param properties 插件属性
*/
@Override
public void setProperties(Properties properties) {
// 当前插件不需要额外属性
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
SQL 执行耗时指标建议:
| 指标 | 标签 |
|---|---|
mybatis.sql.execution | mapper、type |
mybatis.sql.slow.count | mapper、type |
mybatis.sql.error.count | mapper、异常类型 |
mybatis.sql.rows | 谨慎记录,避免高基数 |
不要把完整 SQL 或完整参数作为指标标签。Prometheus 标签必须低基数,否则会造成指标爆炸。
慢 SQL 监控
慢 SQL 监控用于定位耗时超过阈值的 SQL。慢 SQL 监控可以同时写日志和打指标,日志用于排查,指标用于告警。
慢 SQL 记录对象:
package io.github.atengk.framework.mybatis;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 慢 SQL 记录对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class SlowSqlRecord {
/**
* Mapper 方法
*/
private String mapperMethod;
/**
* SQL 类型
*/
private String sqlType;
/**
* SQL 摘要
*/
private String sqlSummary;
/**
* 执行耗时,单位毫秒
*/
private Long costMillis;
/**
* TraceId
*/
private String traceId;
/**
* 记录时间
*/
private LocalDateTime recordTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
慢 SQL 告警建议:
| 告警项 | 建议 |
|---|---|
| 单条 SQL 超过 3 秒 | 告警或重点日志 |
| 5 分钟慢 SQL 数量增长 | 告警 |
| 某 Mapper p95 超过 1 秒 | 告警 |
| SQL 错误率升高 | 告警 |
| 慢 SQL 与连接池耗尽同时出现 | 优先处理 SQL |
接口耗时监控
接口耗时监控用于观察 Controller 接口的 p50、p90、p95、p99 和错误率。Spring Boot Actuator 与 Micrometer Observation 能为 Web 请求自动生成指标;也可以通过自定义 AOP 增加业务维度指标。Spring Boot 的 metrics 文档说明,Actuator 自动集成 Micrometer,并能为多类技术自动注册 meter。(Home)
自定义接口耗时注解:
package io.github.atengk.framework.monitor;
import java.lang.annotation.*;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* 接口耗时监控注解
*
* @author Ateng
* @since 2026-05-05
*/
@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface ApiMonitor {
/**
* 业务模块
*
* @return 业务模块
*/
String module();
/**
* 接口名称
*
* @return 接口名称
*/
String name();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
接口耗时切面:
package io.github.atengk.framework.monitor;
import cn.hutool.core.date.StopWatch;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 接口耗时监控切面
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ApiMonitorAspect {
private final MeterRegistry meterRegistry;
/**
* 记录接口耗时
*
* @param joinPoint 切点
* @param apiMonitor 监控注解
* @return 方法返回值
* @throws Throwable 业务异常
*/
@Around("@annotation(apiMonitor)")
public Object around(ProceedingJoinPoint joinPoint, ApiMonitor apiMonitor) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
boolean success = true;
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
success = false;
throw throwable;
} finally {
stopWatch.stop();
long costMillis = stopWatch.getTotalTimeMillis();
meterRegistry.timer(
"business.api.duration",
"module", apiMonitor.module(),
"name", apiMonitor.name(),
"success", String.valueOf(success)
).record(costMillis, TimeUnit.MILLISECONDS);
if (costMillis >= 1000) {
log.warn("接口耗时较高,模块:{},接口:{},耗时:{}ms",
apiMonitor.module(), apiMonitor.name(), costMillis);
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
使用示例:
@ApiMonitor(module = "用户管理", name = "分页查询用户")
@GetMapping("/page")
public ApiResult<PageResult<UserPageVO>> pageUser(@Valid UserPageQuery query) {
return ApiResult.success(userService.pageUser(query));
}2
3
4
5
事务耗时监控
事务耗时监控用于定位大事务、长事务和事务中外部调用问题。长事务会长时间占用连接和锁,是数据库性能问题的重要来源。
事务耗时监控切面:
package io.github.atengk.framework.monitor;
import cn.hutool.core.date.StopWatch;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;
/**
* 事务耗时监控切面
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class TransactionMonitorAspect {
private final MeterRegistry meterRegistry;
/**
* 记录事务方法耗时
*
* @param joinPoint 切点
* @param transactional 事务注解
* @return 方法返回值
* @throws Throwable 业务异常
*/
@Around("@annotation(transactional)")
public Object around(ProceedingJoinPoint joinPoint, Transactional transactional) throws Throwable {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
String methodName = joinPoint.getSignature().toShortString();
try {
return joinPoint.proceed();
} finally {
stopWatch.stop();
long costMillis = stopWatch.getTotalTimeMillis();
meterRegistry.timer(
"business.transaction.duration",
"method", methodName
).record(costMillis, TimeUnit.MILLISECONDS);
if (costMillis >= 3000) {
log.warn("事务耗时过长,方法:{},耗时:{}ms", methodName, costMillis);
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
事务耗时告警建议:
| 场景 | 建议 |
|---|---|
| 单个事务超过 3 秒 | 记录警告 |
| 单个事务超过 10 秒 | 告警 |
| 批量事务持续升高 | 检查导入导出 |
| 事务中远程调用 | 必须治理 |
| 事务中大查询 | 拆分或移出事务 |
异常监控
异常监控用于统计业务异常、系统异常、数据库异常、权限异常、参数校验异常。建议在全局异常处理器中统一打点。
异常指标记录器:
package io.github.atengk.framework.monitor;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* 异常指标记录器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
@RequiredArgsConstructor
public class ExceptionMetricsRecorder {
private final MeterRegistry meterRegistry;
/**
* 记录异常指标
*
* @param exceptionType 异常类型
* @param module 模块名称
*/
public void record(String exceptionType, String module) {
meterRegistry.counter(
"business.exception.count",
"type", exceptionType,
"module", module
).increment();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
全局异常处理器中记录异常:
package io.github.atengk.framework.web;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.framework.monitor.ExceptionMetricsRecorder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
/**
* 全局异常处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final ExceptionMetricsRecorder exceptionMetricsRecorder;
/**
* 处理业务异常
*
* @param exception 业务异常
* @return 统一响应
*/
@ExceptionHandler(BusinessException.class)
public ApiResult<Void> handleBusinessException(BusinessException exception) {
exceptionMetricsRecorder.record("BusinessException", "business");
log.warn("业务异常:{}", exception.getMessage());
return ApiResult.fail(exception.getMessage());
}
/**
* 处理参数校验异常
*
* @param exception 参数校验异常
* @return 统一响应
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResult<Void> handleValidationException(MethodArgumentNotValidException exception) {
exceptionMetricsRecorder.record("ValidationException", "web");
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("参数校验失败");
log.warn("参数校验异常:{}", message);
return ApiResult.fail(message);
}
/**
* 处理系统异常
*
* @param exception 系统异常
* @return 统一响应
*/
@ExceptionHandler(Exception.class)
public ApiResult<Void> handleException(Exception exception) {
exceptionMetricsRecorder.record(exception.getClass().getSimpleName(), "system");
log.error("系统异常", exception);
return ApiResult.fail("系统异常,请联系管理员");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
异常监控建议:
| 异常类型 | 处理方式 |
|---|---|
| 参数校验异常 | warn 日志 + 指标 |
| 业务异常 | warn 日志 + 指标 |
| 权限异常 | warn 日志 + 安全日志 |
| 数据库异常 | error 日志 + 告警 |
| 空指针等系统异常 | error 日志 + 告警 |
| 重复键异常 | 可单独统计 |
| 外部接口异常 | 按供应商、接口统计 |
指标采集
指标采集建议使用 Actuator + Micrometer + Prometheus。Spring Boot 会自动配置 MeterRegistry,并根据 classpath 中的 registry 实现导出到对应系统;/actuator/metrics 可以查看应用已有指标,/actuator/prometheus 可以供 Prometheus 抓取。(Home)
Prometheus 抓取配置示例:
scrape_configs:
# Spring Boot 应用指标采集
- job_name: 'mybatis-plus-demo'
metrics_path: '/actuator/prometheus'
scrape_interval: 15s
static_configs:
- targets:
- '127.0.0.1:8080'2
3
4
5
6
7
8
自定义业务指标示例:
package io.github.atengk.module.order.monitor;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
/**
* 订单指标记录器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
@RequiredArgsConstructor
public class OrderMetricsRecorder {
private final MeterRegistry meterRegistry;
/**
* 记录订单创建
*
* @param tenantId 租户 ID
* @param payAmount 支付金额
*/
public void recordOrderCreated(Long tenantId, BigDecimal payAmount) {
meterRegistry.counter(
"business.order.created.count",
"tenant", String.valueOf(tenantId)
).increment();
meterRegistry.summary(
"business.order.pay.amount",
"tenant", String.valueOf(tenantId)
).record(payAmount.doubleValue());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
指标命名建议:
| 类型 | 示例 |
|---|---|
| 接口耗时 | business.api.duration |
| SQL 耗时 | mybatis.sql.execution |
| 慢 SQL 数量 | mybatis.sql.slow.count |
| 异常数量 | business.exception.count |
| 订单创建数 | business.order.created.count |
| 导出任务数 | business.export.task.count |
| MQ 消费耗时 | business.mq.consume.duration |
| 事务耗时 | business.transaction.duration |
指标标签不要放用户 ID、订单号、手机号、SQL 文本、请求参数等高基数或敏感数据。
链路追踪
链路追踪用于把一次请求在网关、应用、数据库、Redis、MQ、外部接口之间的调用串起来。Spring Boot Actuator 提供 Micrometer Tracing 自动配置,Spring Boot 文档说明其支持 OpenTelemetry with OTLP 和 OpenZipkin Brave with Zipkin。(Home)
使用 Zipkin + Brave 示例依赖:
<!-- Micrometer Tracing 与 Brave 桥接 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<!-- Zipkin Reporter,用于发送链路数据到 Zipkin -->
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>2
3
4
5
6
7
8
9
10
11
配置示例:
management:
tracing:
sampling:
# 采样比例,1.0 表示全部采样,生产环境可按流量调低
probability: 1.0
zipkin:
tracing:
# Zipkin 服务地址
endpoint: http://127.0.0.1:9411/api/v2/spans
logging:
pattern:
# 日志中输出 traceId 和 spanId
level: "%5p [traceId=%X{traceId:-},spanId=%X{spanId:-}]"2
3
4
5
6
7
8
9
10
11
12
13
14
如果项目已经自定义 TraceIdFilter,建议和 Micrometer Tracing 统一:日志中使用 tracing 自动写入的 traceId、spanId,业务操作日志中也保存当前 traceId。
链路追踪建议:
| 场景 | 建议 |
|---|---|
| HTTP 请求 | 自动生成 trace |
| Feign/RestClient | 使用自动观测能力 |
| MQ 消息 | 消息 Header 传递 trace |
| 异步任务 | 线程池传递 MDC |
| 操作日志 | 保存 traceId |
| 慢 SQL 日志 | 保存 traceId |
| 异常响应 | 返回 traceId 便于排查 |
| 采样率 | 生产按流量调整 |
告警规则
告警规则用于将异常指标转为运维动作。告警不应只看单点指标,应组合接口耗时、错误率、数据库连接池、慢 SQL、JVM、磁盘、Redis、MQ 等指标判断。
Prometheus 告警规则示例:
groups:
- name: mybatis-plus-demo-alerts
rules:
# 接口错误率过高
- alert: ApiErrorRateHigh
expr: rate(http_server_requests_seconds_count{status=~"5.."}[5m]) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "接口 5xx 错误率过高"
description: "应用 {{ $labels.application }} 最近 5 分钟 5xx 错误率过高"
# 数据库连接池接近耗尽
- alert: JdbcConnectionPoolHigh
expr: jdbc_connections_active / jdbc_connections_max > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "数据库连接池活跃连接过高"
description: "活跃连接占比超过 80%,请检查慢 SQL 或长事务"
# 慢 SQL 数量增长
- alert: SlowSqlIncreased
expr: increase(mybatis_sql_slow_count[5m]) > 10
for: 2m
labels:
severity: warning
annotations:
summary: "慢 SQL 数量异常增长"
description: "最近 5 分钟慢 SQL 超过 10 次"
# JVM 堆内存过高
- alert: JvmMemoryHigh
expr: jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} > 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "JVM 堆内存使用率过高"
description: "JVM 堆内存使用率超过 85%"2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
告警规则建议:
| 告警项 | 建议 |
|---|---|
| 接口 5xx | 5 分钟持续升高 |
| 接口 p95 | 超过 SLA 持续 5 分钟 |
| 慢 SQL | 5 分钟内数量异常 |
| 数据库连接池 | 活跃连接超过 80% |
| Hikari 超时 | 出现连接超时 |
| 事务耗时 | 超过 10 秒 |
| MQ 消费失败 | 失败数增长 |
| 导出任务失败 | 连续失败 |
| JVM 内存 | 堆内存超过 85% |
| 磁盘空间 | 可用空间低于 15% |
整体建议:数据库迁移解决“结构可控”,监控可观测性解决“运行可知”。迁移脚本必须可评审、可追踪、可回滚;监控指标必须能定位接口、SQL、事务、连接池和异常链路。对于 Spring Boot 3 + MyBatis-Plus 项目,Flyway 或 Liquibase、Actuator、Micrometer、Prometheus、链路追踪和日志 TraceId 应作为生产级基础能力统一建设。
部署与环境
部署与环境用于保证同一套 Spring Boot 3 + MyBatis-Plus 应用在本地、测试、预发布和生产环境中按一致方式运行。核心原则是:代码包一致,配置外置;环境隔离,数据隔离;生产关闭调试日志;数据库连接池、SQL 日志、迁移脚本、健康检查和监控端点按环境差异化配置。
本地开发环境
本地开发环境用于快速启动、调试接口、验证 SQL 和排查 MyBatis-Plus 配置。该环境可以开启 SQL 日志、接口文档、调试端点,但不应连接生产数据库。
文件位置:src/main/resources/application-dev.yml
server:
port: 8080
spring:
application:
name: mybatis-plus-demo
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/mybatis_plus_demo_dev?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
hikari:
# 本地开发连接池不需要太大
pool-name: DevHikariPool
minimum-idle: 2
maximum-pool-size: 10
connection-timeout: 30000
data:
redis:
host: 127.0.0.1
port: 6379
database: 0
timeout: 3s
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
type-aliases-package: io.github.atengk.**.entity
configuration:
# 本地开发可开启 SQL 标准输出,生产必须关闭
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
global-config:
banner: false
db-config:
id-type: assign_id
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
logging:
level:
io.github.atengk: debug
com.baomidou.mybatisplus: debug2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
本地启动命令:
# 使用 dev profile 启动
mvn spring-boot:run -Dspring-boot.run.profiles=dev
# 或者打包后启动
java -jar target/mybatis-plus-demo.jar --spring.profiles.active=dev2
3
4
5
本地环境建议:
| 配置项 | 建议 |
|---|---|
| 数据库 | 本地 MySQL 或 Docker MySQL |
| Redis | 本地 Redis |
| SQL 日志 | 可以开启 |
| 接口文档 | 可以开启 |
| Flyway/Liquibase | 可以开启 |
| 测试数据 | 可以自动初始化 |
| 日志级别 | debug 可接受 |
| 外部接口 | 使用 Mock 或沙箱环境 |
测试环境
测试环境用于功能测试、接口联调、自动化测试和集成测试。测试环境应尽量接近生产配置,但可以保留必要的调试能力,例如 SQL 摘要日志、Actuator 内网访问、测试数据初始化。
文件位置:src/main/resources/application-test.yml
server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_HOST:127.0.0.1}:${DB_PORT:3306}/${DB_NAME:mybatis_plus_demo_test}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:123456}
hikari:
pool-name: TestHikariPool
minimum-idle: 5
maximum-pool-size: 20
connection-timeout: 30000
leak-detection-threshold: 60000
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
database: ${REDIS_DATABASE:1}
password: ${REDIS_PASSWORD:}
timeout: 3s
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
configuration:
# 测试环境可按需开启,接口压测时建议关闭
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
banner: false
management:
endpoints:
web:
exposure:
# 测试环境允许暴露更多端点,但仍建议内网访问
include: health,info,metrics,prometheus,loggers2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
测试环境建议:
| 配置项 | 建议 |
|---|---|
| 数据库 | 独立测试库 |
| Redis | 独立 Redis database 或独立实例 |
| SQL 日志 | 功能测试可开,压测关闭 |
| 数据迁移 | 启用 Flyway/Liquibase |
| 测试数据 | 可通过迁移脚本或测试平台初始化 |
| 监控端点 | 内网开放 |
| 外部接口 | 使用测试环境地址 |
| 定时任务 | 按需开启,避免影响测试数据 |
预发布环境
预发布环境用于发布前验证,配置应尽量与生产一致。预发布环境不建议开启完整 SQL 日志,也不应自动初始化测试数据。预发布应重点验证数据库迁移脚本、配置中心、灰度开关、外部依赖、监控指标和回滚方案。
文件位置:src/main/resources/application-stage.yml
server:
port: ${SERVER_PORT:8080}
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_HOST}:${DB_PORT:3306}/${DB_NAME}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
pool-name: StageHikariPool
minimum-idle: ${DB_POOL_MIN_IDLE:5}
maximum-pool-size: ${DB_POOL_MAX_SIZE:30}
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
configuration:
# 预发布环境关闭完整 SQL 标准输出
log-impl:
global-config:
banner: false
logging:
level:
root: info
io.github.atengk: info
com.baomidou.mybatisplus: warn2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
预发布环境建议:
| 配置项 | 建议 |
|---|---|
| 数据库结构 | 与生产一致 |
| 数据量 | 尽量接近生产特征 |
| SQL 日志 | 关闭完整 SQL |
| 数据迁移 | 必须验证 |
| 配置中心 | 与生产同一接入方式 |
| 监控告警 | 必须接入 |
| 灰度开关 | 必须验证 |
| 回滚脚本 | 必须演练或至少评审 |
生产环境
生产环境以稳定、安全、可观测为优先。生产环境必须关闭完整 SQL 日志,限制 Actuator 端点暴露,敏感配置通过环境变量、配置中心或密钥系统注入,不允许写死在 jar 包中。
文件位置:src/main/resources/application-prod.yml
server:
port: ${SERVER_PORT:8080}
shutdown: graceful
spring:
lifecycle:
# 优雅停机等待时间
timeout-per-shutdown-phase: 30s
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${DB_HOST}:${DB_PORT:3306}/${DB_NAME}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=false
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
pool-name: ProdHikariPool
minimum-idle: ${DB_POOL_MIN_IDLE:10}
maximum-pool-size: ${DB_POOL_MAX_SIZE:50}
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 0
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
database: ${REDIS_DATABASE:0}
password: ${REDIS_PASSWORD}
timeout: 3s
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
configuration:
# 生产环境不要配置 StdOutImpl
log-impl:
map-underscore-to-camel-case: true
global-config:
banner: false
db-config:
id-type: assign_id
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
management:
endpoints:
web:
exposure:
# 生产只暴露必要端点,并通过内网或网关鉴权保护
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
logging:
level:
root: info
io.github.atengk: info
com.baomidou.mybatisplus: warn
org.mybatis: warn2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
生产环境建议:
| 配置项 | 建议 |
|---|---|
| SQL 日志 | 关闭完整 SQL |
| 慢 SQL | 使用摘要日志和数据库慢查询 |
| Actuator | 只暴露必要端点 |
| 密码密钥 | 使用环境变量、配置中心或 Secret |
| 数据库迁移 | 发布流程中执行并审计 |
| 日志脱敏 | 必须启用 |
| 优雅停机 | 必须启用 |
| 连接池 | 按数据库容量评估 |
| 定时任务 | 多实例部署时要避免重复执行 |
Docker 部署
Docker 部署用于将 Spring Boot 应用打包为镜像,实现运行环境一致。镜像中不要写入数据库密码、Redis 密码等敏感配置,应通过环境变量或编排平台注入。
文件位置:Dockerfile
# 使用 JDK 运行时镜像,生产可替换为内部基础镜像
FROM eclipse-temurin:21-jre
# 应用目录
WORKDIR /app
# 复制构建产物
COPY target/mybatis-plus-demo.jar /app/app.jar
# JVM 和 Spring 参数通过环境变量传入
ENV JAVA_OPTS="-Xms512m -Xmx512m"
ENV SPRING_PROFILES_ACTIVE="prod"
# 暴露应用端口
EXPOSE 8080
# 启动应用
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app/app.jar --spring.profiles.active=${SPRING_PROFILES_ACTIVE}"]2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
构建和运行命令:
# Maven 打包
mvn clean package -DskipTests
# 构建镜像
docker build -t mybatis-plus-demo:1.0.0 .
# 运行容器
docker run -d \
--name mybatis-plus-demo \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=prod \
-e DB_HOST=192.168.1.10 \
-e DB_PORT=3306 \
-e DB_NAME=mybatis_plus_demo \
-e DB_USERNAME=app_user \
-e DB_PASSWORD=app_password \
-e REDIS_HOST=192.168.1.11 \
-e REDIS_PORT=6379 \
-e REDIS_PASSWORD=redis_password \
mybatis-plus-demo:1.0.02
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Docker 部署建议:
| 项目 | 建议 |
|---|---|
| 镜像 | 使用固定 JDK 主版本 |
| 配置 | 通过环境变量注入 |
| 密码 | 不写入镜像 |
| 日志 | 输出到 stdout,由平台采集 |
| 健康检查 | 使用 /actuator/health |
| 优雅停机 | 配合 server.shutdown=graceful |
| 时区 | 镜像或 JVM 统一设置 |
| 内存 | 设置合理 JAVA_OPTS |
Kubernetes 部署
Kubernetes 部署适合多实例、滚动发布、自动重启、服务发现、配置外置和弹性伸缩。生产部署建议使用 ConfigMap 管理非敏感配置,使用 Secret 管理数据库密码、Redis 密码等敏感配置。
文件位置:k8s/configmap.yml
apiVersion: v1
kind: ConfigMap
metadata:
name: mybatis-plus-demo-config
namespace: demo
data:
SPRING_PROFILES_ACTIVE: "prod"
SERVER_PORT: "8080"
DB_HOST: "mysql.demo.svc.cluster.local"
DB_PORT: "3306"
DB_NAME: "mybatis_plus_demo"
REDIS_HOST: "redis.demo.svc.cluster.local"
REDIS_PORT: "6379"
DB_POOL_MIN_IDLE: "10"
DB_POOL_MAX_SIZE: "50"2
3
4
5
6
7
8
9
10
11
12
13
14
15
文件位置:k8s/secret.yml
apiVersion: v1
kind: Secret
metadata:
name: mybatis-plus-demo-secret
namespace: demo
type: Opaque
stringData:
DB_USERNAME: "app_user"
DB_PASSWORD: "app_password"
REDIS_PASSWORD: "redis_password"2
3
4
5
6
7
8
9
10
文件位置:k8s/deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mybatis-plus-demo
namespace: demo
spec:
replicas: 2
selector:
matchLabels:
app: mybatis-plus-demo
template:
metadata:
labels:
app: mybatis-plus-demo
spec:
containers:
- name: mybatis-plus-demo
image: mybatis-plus-demo:1.0.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
envFrom:
# 注入普通配置
- configMapRef:
name: mybatis-plus-demo-config
# 注入敏感配置
- secretRef:
name: mybatis-plus-demo-secret
resources:
requests:
cpu: "500m"
memory: "1024Mi"
limits:
cpu: "2000m"
memory: "2048Mi"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 20
timeoutSeconds: 3
lifecycle:
preStop:
exec:
command:
- sh
- -c
- sleep 102
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
文件位置:k8s/service.yml
apiVersion: v1
kind: Service
metadata:
name: mybatis-plus-demo
namespace: demo
spec:
selector:
app: mybatis-plus-demo
ports:
- name: http
port: 8080
targetPort: 8080
type: ClusterIP2
3
4
5
6
7
8
9
10
11
12
13
部署命令:
# 创建命名空间
kubectl create namespace demo
# 应用配置和密钥
kubectl apply -f k8s/configmap.yml
kubectl apply -f k8s/secret.yml
# 部署应用和服务
kubectl apply -f k8s/deployment.yml
kubectl apply -f k8s/service.yml
# 查看发布状态
kubectl rollout status deployment/mybatis-plus-demo -n demo
# 查看应用日志
kubectl logs -f deployment/mybatis-plus-demo -n demo2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Kubernetes 部署建议:
| 项目 | 建议 |
|---|---|
| 配置 | ConfigMap |
| 密码 | Secret |
| 健康检查 | readiness + liveness |
| 优雅停机 | preStop + graceful shutdown |
| 日志 | stdout |
| 资源限制 | 设置 requests 和 limits |
| 滚动发布 | 默认 rollingUpdate |
| 数据库迁移 | 不建议每个 Pod 同时执行迁移 |
环境变量配置
环境变量配置用于让同一个 jar 或镜像在不同环境中运行。Spring Boot 支持从环境变量读取配置,常见写法是 ${ENV_NAME:defaultValue}。
推荐环境变量清单:
| 环境变量 | 说明 | 示例 |
|---|---|---|
SPRING_PROFILES_ACTIVE | 当前环境 | prod |
SERVER_PORT | 服务端口 | 8080 |
DB_HOST | 数据库地址 | mysql.prod.local |
DB_PORT | 数据库端口 | 3306 |
DB_NAME | 数据库名 | mybatis_plus_demo |
DB_USERNAME | 数据库用户名 | app_user |
DB_PASSWORD | 数据库密码 | ****** |
REDIS_HOST | Redis 地址 | redis.prod.local |
REDIS_PORT | Redis 端口 | 6379 |
REDIS_PASSWORD | Redis 密码 | ****** |
DB_POOL_MAX_SIZE | 最大连接数 | 50 |
JAVA_OPTS | JVM 参数 | -Xms1g -Xmx1g |
应用配置示例:
spring:
datasource:
url: jdbc:mysql://${DB_HOST}:${DB_PORT:3306}/${DB_NAME}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
minimum-idle: ${DB_POOL_MIN_IDLE:10}
maximum-pool-size: ${DB_POOL_MAX_SIZE:50}2
3
4
5
6
7
8
环境变量建议:
| 项目 | 建议 |
|---|---|
| 密码 | 必须外置 |
| 数据库地址 | 必须外置 |
| Redis 地址 | 必须外置 |
| JVM 参数 | 外置 |
| profile | 外置 |
| 默认值 | 非敏感配置可设置默认值 |
| 敏感默认值 | 不建议设置 |
| 日志 | 启动时不要打印密码 |
配置中心集成
配置中心用于集中管理多环境配置、动态配置和灰度开关。常见方案包括 Nacos、Apollo、Spring Cloud Config、Kubernetes ConfigMap。MyBatis-Plus 本身不依赖配置中心,但数据源、Redis、日志级别、业务开关、导出限制、缓存 TTL 等适合放入配置中心。
以 Nacos 为例,配置结构建议:
mybatis-plus-demo-dev.yml
mybatis-plus-demo-test.yml
mybatis-plus-demo-stage.yml
mybatis-plus-demo-prod.yml2
3
4
配置中心中的生产配置示例:
spring:
datasource:
url: jdbc:mysql://${DB_HOST}:${DB_PORT:3306}/${DB_NAME}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: ${DB_POOL_MAX_SIZE:50}
app:
export:
# 单次同步导出最大数量
sync-max-size: 10000
# 异步导出最大数量
async-max-size: 1000000
cache:
# 字典缓存 TTL,单位秒
dict-ttl-seconds: 21600
security:
# 是否开启敏感日志脱敏
log-desensitize-enabled: true2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
配置属性绑定类示例:
文件位置:src/main/java/io/github/atengk/framework/config/AppBizProperties.java
package io.github.atengk.framework.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 应用业务配置属性
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "app")
public class AppBizProperties {
/**
* 导出配置
*/
private Export export = new Export();
/**
* 缓存配置
*/
private Cache cache = new Cache();
/**
* 安全配置
*/
private Security security = new Security();
/**
* 导出配置
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public static class Export {
/**
* 同步导出最大数量
*/
private Integer syncMaxSize = 10000;
/**
* 异步导出最大数量
*/
private Integer asyncMaxSize = 1000000;
}
/**
* 缓存配置
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public static class Cache {
/**
* 字典缓存 TTL 秒数
*/
private Long dictTtlSeconds = 21600L;
}
/**
* 安全配置
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public static class Security {
/**
* 是否开启日志脱敏
*/
private Boolean logDesensitizeEnabled = true;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
配置中心建议:
| 配置类型 | 是否建议放配置中心 |
|---|---|
| 数据库地址 | 可以,密码仍建议 Secret |
| Redis 地址 | 可以 |
| 业务开关 | 推荐 |
| 导出限制 | 推荐 |
| 缓存 TTL | 推荐 |
| 日志级别 | 可以动态调整 |
| SQL 日志开关 | 生产默认关闭 |
| 密码密钥 | 优先 Secret 或专门密钥系统 |
数据库连接池参数
数据库连接池参数需要结合应用实例数、每实例最大连接数、数据库最大连接数、慢 SQL 情况和事务耗时评估。不能简单把 maximum-pool-size 调得很大,连接数过高会增加数据库压力。
Hikari 推荐配置示例:
spring:
datasource:
hikari:
# 连接池名称,监控指标中会使用
pool-name: ProdHikariPool
# 最小空闲连接数
minimum-idle: 10
# 最大连接数,需要结合数据库 max_connections 和实例数评估
maximum-pool-size: 50
# 获取连接超时时间
connection-timeout: 30000
# 空闲连接最大存活时间
idle-timeout: 600000
# 连接最大生命周期,建议小于数据库连接超时时间
max-lifetime: 1800000
# 连接校验超时时间
validation-timeout: 50002
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
连接池估算方式:
数据库可用连接数 = 数据库 max_connections - 预留连接数
单实例最大连接数 = 数据库可用连接数 / 应用实例数2
连接池建议:
| 参数 | 建议 |
|---|---|
maximum-pool-size | 不超过数据库承载能力 |
minimum-idle | 通常为最大连接数的 20% 到 50% |
connection-timeout | 30 秒以内 |
max-lifetime | 小于数据库连接超时时间 |
leak-detection-threshold | 测试环境可开,生产谨慎 |
| 多数据源 | 每个数据源都要单独评估 |
| 大事务 | 优先治理 SQL,不是盲目加连接 |
SQL 日志生产关闭策略
生产环境必须关闭完整 SQL 输出。StdOutImpl 会把 SQL 打到标准输出,容易造成日志膨胀和敏感数据泄露。生产只建议保留慢 SQL 摘要、异常 SQL 摘要、数据库慢查询日志和 APM 链路。
生产关闭配置:
mybatis-plus:
configuration:
# 生产环境不配置 StdOutImpl
log-impl:
logging:
level:
com.baomidou.mybatisplus: warn
org.mybatis: warn
io.github.atengk: info2
3
4
5
6
7
8
9
10
如果使用 p6spy,应在生产 profile 禁用:
spring:
datasource:
# 生产使用原始 MySQL 驱动,不使用 P6SpyDriver
driver-class-name: com.mysql.cj.jdbc.Driver2
3
4
SQL 日志环境策略:
| 环境 | 策略 |
|---|---|
| 本地 | 可开启完整 SQL |
| 测试 | 功能测试可开启,压测关闭 |
| 预发布 | 关闭完整 SQL |
| 生产 | 关闭完整 SQL |
| 问题排查 | 临时提高指定包日志级别 |
| 慢 SQL | 使用摘要日志和数据库慢查询 |
| 敏感参数 | 不记录完整值 |
常见问题
常见问题排查应遵循顺序:先确认依赖版本和配置文件,再确认扫描路径、Mapper XML 路径、实体注解、插件顺序、数据源上下文,最后确认 SQL 和数据库表结构。不要直接怀疑 MyBatis-Plus 本身,绝大多数问题来自扫描路径、配置项、字段名、插件未注册或事务调用方式错误。
Mapper 无法注入
Mapper 无法注入通常表现为 NoSuchBeanDefinitionException 或启动时报找不到 Mapper Bean。常见原因是未配置 @MapperScan、扫描路径错误、Mapper 接口没有被 Spring 扫描、Mapper 所在模块未被依赖。
启动类配置示例:
package io.github.atengk;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 应用启动类
*
* @author Ateng
* @since 2026-05-05
*/
@MapperScan("io.github.atengk.**.mapper")
@SpringBootApplication
public class MyBatisPlusDemoApplication {
/**
* 启动应用
*
* @param args 启动参数
*/
public static void main(String[] args) {
SpringApplication.run(MyBatisPlusDemoApplication.class, args);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
排查清单:
| 检查项 | 说明 |
|---|---|
@MapperScan | 是否扫描到 Mapper 包 |
| Mapper 包名 | 是否在启动类扫描范围内 |
| 多模块依赖 | Web 模块是否依赖 Mapper 所在模块 |
| 接口继承 | 是否继承 BaseMapper<Entity> |
| Spring profile | 是否加载了正确模块配置 |
| 测试类 | 测试环境是否也配置了 MapperScan |
XML 无法加载
XML 无法加载通常表现为自定义 Mapper 方法报 Invalid bound statement not found。常见原因是 mapper-locations 配置错误、XML 未打包进 classpath、namespace 与 Mapper 接口全限定名不一致、方法 ID 不一致。
配置示例:
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml2
XML 示例:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.atengk.module.system.user.mapper.UserMapper">
<select id="selectUserDetail" resultType="io.github.atengk.module.system.user.vo.UserDetailVO">
SELECT
id,
username,
nickname,
phone,
status
FROM sys_user
WHERE id = #{id}
AND deleted = 0
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
排查清单:
| 检查项 | 说明 |
|---|---|
mapper-locations | 是否匹配 XML 路径 |
| XML 目录 | 是否在 src/main/resources/mapper |
| namespace | 是否等于 Mapper 接口全限定名 |
| select id | 是否等于 Mapper 方法名 |
| 参数名 | 是否使用 @Param |
| 打包结果 | jar 中是否包含 XML |
表字段映射失败
表字段映射失败通常表现为字段查询出来是 null,或插入更新字段名错误。常见原因是数据库字段名和实体属性不匹配、未开启驼峰映射、字段需要 @TableField 但未配置、ResultMap 映射不完整。
全局驼峰配置:
mybatis-plus:
configuration:
map-underscore-to-camel-case: true2
3
实体字段显式映射:
package io.github.atengk.module.system.user.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/**
* 用户实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("sys_user")
public class UserEntity {
/**
* 用户名
*/
@TableField("username")
private String username;
/**
* 创建时间字符串,非表字段
*/
@TableField(exist = false)
private String createTimeText;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
排查清单:
| 检查项 | 说明 |
|---|---|
| 驼峰配置 | user_name 是否映射 userName |
| 字段名 | 数据库字段是否真实存在 |
@TableField | 特殊字段是否显式声明 |
| 非表字段 | 是否加 exist = false |
| XML ResultMap | 是否映射了别名 |
| SQL 别名 | VO 查询是否使用正确别名 |
主键未回填
主键未回填通常表现为 save(entity) 后 entity.getId() 仍为 null。常见原因是未配置主键策略、实体缺少 @TableId、数据库自增策略和实体策略不匹配、手写 XML insert 没有配置回填。
雪花 ID 推荐配置:
package io.github.atengk.common.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Getter;
import lombok.Setter;
/**
* 主键基础实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public abstract class IdBaseEntity {
/**
* 主键 ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
数据库自增配置:
@TableId(type = IdType.AUTO)
private Long id;2
排查清单:
| 检查项 | 说明 |
|---|---|
@TableId | 主键字段是否标注 |
IdType | 是否和数据库策略匹配 |
| 自增主键 | 表字段是否 AUTO_INCREMENT |
| 雪花 ID | 字段类型是否为 BIGINT / Long |
| XML insert | 是否配置 useGeneratedKeys |
| 批量保存 | 是否期望每条都回填 |
分页插件不生效
分页插件不生效通常表现为查询没有 LIMIT,或返回全量数据。常见原因是未注册 PaginationInnerInterceptor、插件顺序错误、自定义 SQL 没有传 Page 参数、版本较新但缺少 mybatis-plus-jsqlparser 依赖。
分页插件配置:
package io.github.atengk.framework.mybatis;
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;
/**
* MyBatis-Plus 插件配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
public class MyBatisPlusConfig {
/**
* 配置 MyBatis-Plus 拦截器
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInnerInterceptor.setMaxLimit(200L);
// 分页插件建议放到最后
interceptor.addInnerInterceptor(paginationInnerInterceptor);
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
自定义 SQL 分页 Mapper:
IPage<UserPageVO> selectUserPage(Page<UserPageVO> page, @Param("query") UserPageQuery query);排查清单:
| 检查项 | 说明 |
|---|---|
| 插件 Bean | 是否注册 MybatisPlusInterceptor |
| 分页插件 | 是否添加 PaginationInnerInterceptor |
| 插件顺序 | 分页插件是否最后添加 |
| 依赖 | 是否缺少 jsqlparser 相关依赖 |
| Mapper 参数 | 自定义 SQL 是否传入 Page |
| 返回类型 | 是否返回 IPage 或 Page |
| 多数据源 | 每个 SqlSessionFactory 是否都配置插件 |
逻辑删除不生效
逻辑删除不生效表现为删除后数据被物理删除,或查询仍能查到已删除数据。常见原因是实体字段没有 @TableLogic、全局逻辑删除配置错误、自定义 SQL 未加 deleted = 0、字段类型和值不匹配。
配置示例:
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 02
3
4
5
6
实体字段:
/**
* 逻辑删除标识:0未删除,1已删除
*/
@TableLogic(value = "0", delval = "1")
private Integer deleted;2
3
4
5
排查清单:
| 检查项 | 说明 |
|---|---|
| 字段注解 | 是否添加 @TableLogic |
| 全局配置 | 删除值和未删除值是否正确 |
| 字段类型 | 数据库是否是 TINYINT,实体是否 Integer |
| 自定义 SQL | XML 是否手动过滤 deleted = 0 |
| 物理删除 | 是否调用了自定义 delete SQL |
| 数据库默认值 | deleted 是否默认为 0 |
乐观锁不生效
乐观锁不生效表现为并发修改没有冲突,旧版本数据仍然更新成功。常见原因是未注册 OptimisticLockerInnerInterceptor、实体缺少 @Version、更新时没有带 version 字段、自定义 SQL 未处理 version 条件。
插件配置:
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 乐观锁插件配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
public class OptimisticLockerConfig {
/**
* 配置乐观锁插件
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor optimisticLockerMybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
实体字段:
/**
* 乐观锁版本号
*/
@Version
private Integer version;2
3
4
5
排查清单:
| 检查项 | 说明 |
|---|---|
| 插件 | 是否注册乐观锁插件 |
| 注解 | version 字段是否加 @Version |
| 更新对象 | updateById 时是否带旧 version |
| 自定义 SQL | 是否手动加 version = version + 1 和 where version = ? |
| Wrapper 复用 | UpdateWrapper 不要复用 |
| 版本字段 | 数据库默认值是否为 0 |
自动填充不生效
自动填充不生效通常表现为 create_time、update_time、create_by 没有值。常见原因是实体字段未配置 fill、没有注册 MetaObjectHandler、使用 lambdaUpdate().set() 或 XML 更新绕过了实体填充逻辑。
实体配置:
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;2
3
4
5
6
7
8
9
10
11
自动填充处理器:
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 自动填充处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
/**
* 新增填充
*
* @param metaObject 元对象
*/
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
/**
* 修改填充
*
* @param metaObject 元对象
*/
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
排查清单:
| 检查项 | 说明 |
|---|---|
@TableField | 是否设置 fill |
| Handler | 是否被 Spring 扫描 |
| 字段名 | 是否使用实体属性名,不是数据库字段名 |
| 字段类型 | Handler 类型是否匹配 |
| 更新方式 | lambdaUpdate().set() 可能需要手动 set |
| XML SQL | 自定义 SQL 需要手动维护审计字段 |
枚举转换失败
枚举转换失败通常表现为入库值不对、查询无法映射、JSON 返回枚举对象而不是编码。常见原因是枚举未实现 IEnum、未使用 @EnumValue、字段类型不匹配、Jackson 序列化未配置。
枚举示例:
package io.github.atengk.module.system.user.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 用户状态枚举
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@AllArgsConstructor
public enum UserStatusEnum {
/**
* 禁用
*/
DISABLED(0, "禁用"),
/**
* 启用
*/
ENABLED(1, "启用");
/**
* 入库编码
*/
@EnumValue
@JsonValue
private final Integer code;
/**
* 状态名称
*/
private final String label;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
实体字段:
/**
* 用户状态
*/
private UserStatusEnum status;2
3
4
排查清单:
| 检查项 | 说明 |
|---|---|
@EnumValue | 是否标注入库字段 |
@JsonValue | 是否控制接口序列化 |
| 字段类型 | 数据库字段类型是否匹配 code |
| 旧数据 | 数据库是否存在枚举未定义值 |
| TypeHandler | 是否被覆盖或冲突 |
| VO 返回 | 是否需要返回 code + label |
LocalDateTime 转换异常
LocalDateTime 转换异常通常发生在 JSON 入参、JSON 出参、数据库时间字段映射。Spring Boot 3 默认支持 Java Time,但项目中如果自定义了 ObjectMapper 或日期格式,可能导致解析失败。
统一 JSON 时间配置:
spring:
jackson:
# 统一 JSON 时间格式
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai2
3
4
5
如果需要明确配置 Java Time 序列化:
package io.github.atengk.framework.jackson;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Jackson 时间序列化配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
public class JacksonTimeConfig {
/**
* 自定义 Jackson 时间模块
*
* @return Jackson 定制器
*/
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
return builder -> builder
.modules(new JavaTimeModule())
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
排查清单:
| 检查项 | 说明 |
|---|---|
| 数据库字段 | MySQL 建议 DATETIME |
| Java 字段 | 使用 LocalDateTime |
| JSON 入参 | 格式是否为 yyyy-MM-dd HH:mm:ss |
| ObjectMapper | 是否注册 JavaTimeModule |
| Redis 序列化 | 是否支持 Java Time |
| Excel 导入 | 是否有日期转换器 |
批量更新性能差
批量更新性能差通常来自循环调用 updateById、每条都单独提交事务、SQL 未命中索引、批量数量过大、逻辑复杂放在循环里查询数据库。
不推荐写法:
for (UserEntity user : users) {
userService.updateById(user);
}2
3
推荐使用批量更新或按条件更新:
@Transactional(rollbackFor = Exception.class)
public void batchDisableUsers(List<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
return;
}
boolean updated = this.lambdaUpdate()
.in(UserEntity::getId, userIds)
.set(UserEntity::getStatus, 0)
.set(UserEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("批量禁用用户失败");
}
log.info("批量禁用用户成功,数量:{}", userIds.size());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
批量处理建议:
| 场景 | 建议 |
|---|---|
| 批量新增 | saveBatch(list, 1000) |
| 批量更新同一字段 | 条件 update |
| 批量更新不同字段 | updateBatchById(list, 1000) |
| 超大批量 | 分批处理 |
| 循环查询 | 先批量查出 Map |
| 索引 | WHERE 条件必须命中索引 |
| 事务 | 控制事务大小 |
事务不回滚
事务不回滚常见原因是方法不是 public、自调用、异常被 catch 后未抛出、抛出的是受检异常但未配置 rollbackFor、事务注解加在接口无效场景、异步线程中事务不属于原线程。
正确写法:
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderCreateDTO dto) {
orderService.saveOrder(dto);
stockService.deductStock(dto.getProductId(), dto.getQuantity());
}2
3
4
5
错误写法示例:
public void outerMethod() {
// 自调用不会经过 Spring AOP,事务可能不生效
this.innerTransactionalMethod();
}
@Transactional(rollbackFor = Exception.class)
public void innerTransactionalMethod() {
// 业务逻辑
}2
3
4
5
6
7
8
9
排查清单:
| 检查项 | 说明 |
|---|---|
| 方法可见性 | 是否 public |
| 自调用 | 是否通过 this 调用事务方法 |
| 异常类型 | 是否配置 rollbackFor = Exception.class |
| catch 异常 | 是否吞掉异常 |
| 数据源 | 是否使用同一个事务管理器 |
| 异步方法 | 异步线程事务独立 |
| 数据库引擎 | MySQL 表是否 InnoDB |
多数据源切换失败
多数据源切换失败通常表现为 @DS 不生效、读写走错库、事务中无法切换数据源。常见原因是注解加在同类自调用方法上、事务先于数据源切换开启、数据源名称错误、Mapper 和 Service 层边界混乱。
Service 维度切换示例:
package io.github.atengk.module.report.service.impl;
import com.baomidou.dynamic.datasource.annotation.DS;
import io.github.atengk.module.report.service.UserReportService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 用户报表服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@DS("report")
public class UserReportServiceImpl implements UserReportService {
/**
* 查询用户报表数量
*
* @return 用户报表数量
*/
@Override
public long countUserReport() {
log.info("查询报表库用户统计");
return 0L;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
排查清单:
| 检查项 | 说明 |
|---|---|
| 数据源名称 | 是否和配置一致 |
| 注解位置 | 建议加 Service 方法或类上 |
| 自调用 | 同类内部调用不生效 |
| 事务顺序 | 事务开启前应完成数据源切换 |
| strict | 严格模式下名称错误会报错 |
| Mapper 维度 | Mapper 切换不如 Service 清晰 |
| 分页插件 | 多数据源 SqlSessionFactory 是否配置插件 |
租户条件未生效
租户条件未生效通常表现为查询到了其他租户数据。常见原因是租户插件未注册、租户上下文为空、表被忽略、自定义 SQL 使用了插件无法解析的复杂语法、使用 @InterceptorIgnore 跳过租户。
租户插件配置示例:
package io.github.atengk.framework.tenant;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import net.sf.jsqlparser.expression.LongValue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 多租户插件配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
public class TenantPluginConfig {
/**
* 配置租户插件
*
* @param currentTenantContext 当前租户上下文
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor tenantMybatisPlusInterceptor(CurrentTenantContext currentTenantContext) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new SimpleTenantLineHandler(currentTenantContext)));
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
租户处理器:
package io.github.atengk.framework.tenant;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import java.util.Set;
/**
* 简单租户处理器
*
* @author Ateng
* @since 2026-05-05
*/
public class SimpleTenantLineHandler implements TenantLineHandler {
private static final Set<String> IGNORE_TABLES = Set.of(
"sys_tenant",
"sys_platform_config"
);
private final CurrentTenantContext currentTenantContext;
public SimpleTenantLineHandler(CurrentTenantContext currentTenantContext) {
this.currentTenantContext = currentTenantContext;
}
/**
* 获取租户 ID 表达式
*
* @return 租户 ID 表达式
*/
@Override
public Expression getTenantId() {
return new LongValue(currentTenantContext.getTenantIdOrDefault());
}
/**
* 获取租户字段名
*
* @return 租户字段名
*/
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
/**
* 判断是否忽略表
*
* @param tableName 表名
* @return 是否忽略
*/
@Override
public boolean ignoreTable(String tableName) {
return IGNORE_TABLES.contains(tableName);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
排查清单:
| 检查项 | 说明 |
|---|---|
| 插件 | 是否注册 TenantLineInnerInterceptor |
| 上下文 | 当前线程是否设置 tenantId |
| 表字段 | 表是否存在 tenant_id |
| 忽略表 | 是否被 ignoreTable 排除 |
| 插件顺序 | 租户插件应在分页前 |
| 自定义 SQL | SQL 是否能被插件解析 |
| 注解忽略 | 是否使用 @InterceptorIgnore(tenantLine = "true") |
| 异步任务 | 是否传递租户上下文 |
自定义 SQL 被插件影响
自定义 SQL 被插件影响通常发生在租户插件、数据权限插件、分页插件、动态表名插件同时存在时。表现可能是 SQL 被追加了租户条件、COUNT SQL 生成错误、JOIN 表别名解析失败、数据权限条件追加位置不符合预期。
常见场景:
| 场景 | 原因 |
|---|---|
| JOIN SQL 报错 | 插件追加条件时表别名不规范 |
| COUNT 错误 | 复杂 SQL 被分页插件优化失败 |
| 租户条件异常 | 表没有 tenant_id 但未忽略 |
| 权限条件异常 | 数据权限插件未识别业务表 |
| UNION 报错 | 插件解析复杂 SQL 有限制 |
| 动态表名异常 | 表名替换与 XML SQL 冲突 |
自定义 SQL 建议明确别名:
<select id="selectUserRolePage" resultType="io.github.atengk.module.system.user.vo.UserRolePageVO">
SELECT
u.id AS user_id,
u.username AS username,
u.nickname AS nickname,
r.role_name AS role_name
FROM sys_user u
INNER JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
INNER JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0
WHERE u.deleted = 0
AND u.status = 1
ORDER BY u.create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
分页 COUNT 优化失败时,可以关闭当前分页对象的 count 优化,或自定义 count SQL:
Page<UserRolePageVO> page = Page.of(query.getPageNum(), query.getPageSize());
page.setOptimizeCountSql(false);2
确实需要忽略插件时,要限制范围并加代码评审:
@InterceptorIgnore(tenantLine = "true")
List<SystemConfigVO> selectPlatformConfigs();2
自定义 SQL 排查清单:
| 检查项 | 说明 |
|---|---|
| 表别名 | 所有表是否使用清晰别名 |
| 字段别名 | VO 映射字段是否有别名 |
| 逻辑删除 | XML 是否手动加 deleted = 0 |
| 租户字段 | 多租户表是否有 tenant_id |
| 忽略表 | 无租户表是否配置忽略 |
| 分页 | 复杂 SQL 是否关闭 count 优化 |
| UNION | 是否需要手写分页或拆 SQL |
| 插件忽略 | 是否经过评审 |
| SQL 日志 | 开发环境查看最终 SQL |
整体建议:部署环境要做到配置外置、环境隔离、生产收敛日志;常见问题要先按扫描、配置、插件、上下文、SQL 的顺序排查。MyBatis-Plus 项目大部分线上故障并不是 CRUD 本身问题,而是环境配置、插件顺序、上下文传递和自定义 SQL 规范不足导致的。
最佳实践
最佳实践用于约束项目开发习惯,减少 MyBatis-Plus 项目中常见的分层混乱、SQL 安全风险、性能问题、并发问题和维护成本。MyBatis-Plus 提供了大量便捷能力,但便捷不等于可以绕过架构边界。项目越大,越要控制 Controller、Service、Mapper、Entity、DTO、VO、Wrapper、自定义 SQL 和事务边界的职责。
优先使用 LambdaWrapper
查询条件、更新条件优先使用 LambdaQueryWrapper、LambdaUpdateWrapper 或 Service 链式 Lambda API。它们通过实体类方法引用字段,能减少硬编码字段名,避免字段重命名后 SQL 条件失效。
推荐写法:
List<UserEntity> users = this.lambdaQuery()
.eq(UserEntity::getTenantId, tenantId)
.eq(UserEntity::getStatus, 1)
.like(StrUtil.isNotBlank(keyword), UserEntity::getUsername, keyword)
.orderByDesc(UserEntity::getCreateTime)
.list();2
3
4
5
6
不推荐写法:
QueryWrapper<UserEntity> wrapper = new QueryWrapper<>();
wrapper.eq("tenant_id", tenantId);
wrapper.eq("status", 1);
wrapper.like(StrUtil.isNotBlank(keyword), "username", keyword);
wrapper.orderByDesc("create_time");2
3
4
5
LambdaWrapper 使用建议:
| 场景 | 推荐方式 |
|---|---|
| 单表查询 | LambdaQueryWrapper |
| 单表更新 | LambdaUpdateWrapper |
| Service 查询 | lambdaQuery() |
| Service 更新 | lambdaUpdate() |
| 动态条件 | 使用条件参数控制 |
| 复杂 SQL | 不强行用 Wrapper,改 XML |
| 排序字段来自前端 | 必须白名单校验 |
典型分页查询示例:
public PageResult<UserPageVO> pageUser(UserPageQuery query) {
query.clean();
Page<UserEntity> page = query.toPage();
Page<UserEntity> result = this.lambdaQuery()
.eq(UserEntity::getTenantId, currentUserContext.getTenantIdOrDefault())
.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername())
.eq(query.getStatus() != null, UserEntity::getStatus, query.getStatus())
.orderByDesc(UserEntity::getCreateTime)
.page(page);
List<UserPageVO> records = userConvert.toPageVOList(result.getRecords());
return PageResult.of(result.getCurrent(), result.getSize(), result.getTotal(), records);
}2
3
4
5
6
7
8
9
10
11
12
13
14
避免 Controller 直接操作 Mapper
Controller 只负责接收请求、参数校验、调用 Service、返回响应,不应直接注入 Mapper。Mapper 属于数据访问层,绕过 Service 会导致业务校验、事务、权限、缓存、操作日志全部失效。
不推荐写法:
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
private final UserMapper userMapper;
@GetMapping("/{id}")
public ApiResult<UserEntity> getUser(@PathVariable Long id) {
return ApiResult.success(userMapper.selectById(id));
}
}2
3
4
5
6
7
8
9
10
11
12
13
推荐写法:
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public ApiResult<UserDetailVO> getUser(@PathVariable Long id) {
return ApiResult.success(userService.getUserDetail(id));
}
}2
3
4
5
6
7
8
9
10
11
12
13
Controller 分层建议:
| 层级 | 职责 |
|---|---|
| Controller | 请求接入、参数校验、响应封装 |
| Service | 业务校验、事务、权限、缓存、日志 |
| Mapper | 数据库访问 |
| XML | 复杂 SQL |
| Convert | DTO、Entity、VO 转换 |
Controller 直接操作 Mapper 的问题:
| 问题 | 影响 |
|---|---|
| 绕过业务校验 | 非法数据可能入库 |
| 绕过事务 | 多表操作不一致 |
| 绕过权限 | 容易越权 |
| 暴露 Entity | 泄露敏感字段 |
| 代码重复 | 多个接口重复写查询逻辑 |
| 难以测试 | 业务逻辑分散 |
避免 Entity 直接暴露给前端
Entity 是数据库表结构映射,不是接口返回协议。Controller 直接返回 Entity 会泄露数据库字段,例如 password、deleted、version、tenantId、createBy 等,也会让前端和数据库结构强绑定。
不推荐写法:
@GetMapping("/{id}")
public ApiResult<UserEntity> getUser(@PathVariable Long id) {
return ApiResult.success(userService.getById(id));
}2
3
4
推荐写法:
@GetMapping("/{id}")
public ApiResult<UserDetailVO> getUser(@PathVariable Long id) {
return ApiResult.success(userService.getUserDetail(id));
}2
3
4
VO 示例:
package io.github.atengk.module.system.user.vo;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/**
* 用户详情返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class UserDetailVO {
/**
* 用户 ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 手机号
*/
private String phone;
/**
* 用户状态
*/
private Integer status;
/**
* 用户状态名称
*/
private String statusName;
/**
* 创建时间
*/
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Entity 与 VO 隔离建议:
| 对象 | 使用位置 |
|---|---|
| Entity | Mapper、Service 内部 |
| AddDTO | 新增接口入参 |
| UpdateDTO | 修改接口入参 |
| Query | 查询接口入参 |
| PageVO | 列表返回 |
| DetailVO | 详情返回 |
| ExportExcel | 导出对象 |
| ImportExcel | 导入对象 |
避免业务逻辑写入 Mapper
Mapper 层只负责数据库访问,不应写业务规则。业务规则应放在 Service 层,例如唯一性校验、状态流转、权限判断、缓存清理、操作日志、事务控制。
不推荐:
public interface UserMapper extends BaseMapper<UserEntity> {
/**
* 禁用用户并处理业务逻辑
*
* @param userId 用户 ID
* @return 影响行数
*/
int disableUserAndCheckPermission(Long userId);
}2
3
4
5
6
7
8
9
10
11
推荐:
@Transactional(rollbackFor = Exception.class)
public void disableUser(Long userId) {
UserEntity entity = this.getRequiredById(userId);
if (ObjectUtil.equals(entity.getId(), currentUserContext.getUserIdOrDefault())) {
throw new BusinessException("不能禁用当前登录用户");
}
boolean updated = this.lambdaUpdate()
.eq(UserEntity::getId, userId)
.set(UserEntity::getStatus, 0)
.update();
if (!updated) {
throw new BusinessException("禁用用户失败");
}
userCacheService.clearUserCache(entity.getTenantId(), userId);
log.info("禁用用户成功,用户ID:{}", userId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Mapper 层可以做的事:
| 类型 | 是否适合 Mapper |
|---|---|
| 单表查询 | 适合 |
| 多表 JOIN 查询 | 适合 |
| 聚合统计 | 适合 |
| 报表 SQL | 适合 |
| 权限判断 | 不适合 |
| 状态流转 | 不适合 |
| 事务控制 | 不适合 |
| 缓存清理 | 不适合 |
| 操作日志 | 不适合 |
避免复杂 SQL 滥用 Wrapper
Wrapper 适合单表、简单动态条件、常规排序和局部更新。复杂 SQL 不要强行用 Wrapper 拼接,尤其是多表 JOIN、UNION、窗口函数、复杂分组统计、报表查询、递归查询等场景。
不推荐用 Wrapper 强行拼复杂 SQL:
queryWrapper.apply("exists (select 1 from sys_role r where r.id = user_role.role_id and r.role_code = {0})", roleCode);推荐放到 XML 中明确维护:
<select id="selectUserRolePage" resultType="io.github.atengk.module.system.user.vo.UserRolePageVO">
SELECT
u.id AS user_id,
u.username AS username,
u.nickname AS nickname,
r.role_name AS role_name
FROM sys_user u
INNER JOIN sys_user_role ur ON ur.user_id = u.id AND ur.deleted = 0
INNER JOIN sys_role r ON r.id = ur.role_id AND r.deleted = 0
WHERE u.tenant_id = #{query.tenantId}
AND u.deleted = 0
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.roleCode != null and query.roleCode != ''">
AND r.role_code = #{query.roleCode}
</if>
ORDER BY u.create_time DESC
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
选择标准:
| 场景 | 推荐方式 |
|---|---|
| 简单单表查询 | LambdaWrapper |
| 简单单表更新 | LambdaUpdateWrapper |
| 动态 WHERE | Wrapper 或 XML |
| 多表 JOIN | XML |
| 聚合统计 | XML |
| UNION | XML |
| 窗口函数 | XML |
| 复杂报表 | XML |
| 数据库函数较多 | XML |
避免无索引模糊查询
LIKE '%keyword%' 很容易导致索引失效,尤其在大表上会造成慢 SQL。用户列表、订单列表、日志列表、报表查询都要谨慎处理模糊查询。
不推荐:
this.lambdaQuery()
.like(UserEntity::getNickname, keyword)
.list();2
3
建议至少控制范围:
this.lambdaQuery()
.eq(UserEntity::getTenantId, tenantId)
.eq(UserEntity::getStatus, 1)
.likeRight(StrUtil.isNotBlank(usernamePrefix), UserEntity::getUsername, usernamePrefix)
.orderByDesc(UserEntity::getCreateTime)
.last("LIMIT 50")
.list();2
3
4
5
6
7
模糊查询建议:
| 场景 | 建议 |
|---|---|
| 用户名 | 优先前缀匹配 likeRight |
| 手机号 | 精确匹配或后四位单独字段 |
| 订单号 | 精确匹配或前缀匹配 |
| 日志内容 | 不建议数据库模糊查大字段 |
| 商品名称 | 小表可模糊,大表用搜索引擎 |
| 备注字段 | 不建议高频模糊查 |
| 多条件查询 | 必须加租户、时间、状态等范围条件 |
| 大表搜索 | 考虑 Elasticsearch、OpenSearch、全文索引 |
索引设计示例:
-- 用户名按租户前缀查询
CREATE INDEX idx_user_tenant_username ON sys_user (tenant_id, username);
-- 订单按租户、状态、创建时间查询
CREATE INDEX idx_order_tenant_status_time ON biz_order (tenant_id, order_status, create_time);2
3
4
5
避免大分页查询
大分页通常指 LIMIT 100000, 20 这类深分页。偏移量越大,数据库需要扫描和丢弃的数据越多,查询越慢。后台管理列表要限制最大页码或最大导出数量,大数据滚动加载建议使用游标分页。
不推荐:
Page<OrderEntity> page = Page.of(10000, 20);
orderService.page(page);2
推荐限制分页深度:
package io.github.atengk.common.query;
import io.github.atengk.common.exception.BusinessException;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Getter;
import lombok.Setter;
/**
* 分页请求参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class PageParam {
/**
* 当前页
*/
@Min(value = 1, message = "当前页不能小于1")
private Long pageNum = 1L;
/**
* 每页条数
*/
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 200, message = "每页条数不能超过200")
private Long pageSize = 10L;
/**
* 校验分页深度
*/
public void validatePageDepth() {
long maxOffset = 10000L;
long offset = (pageNum - 1) * pageSize;
if (offset > maxOffset) {
throw new BusinessException("分页查询过深,请缩小查询条件后重试");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
游标分页示例:
public List<OrderPageVO> listOrderByCursor(Long lastId, Integer size) {
int limit = ObjectUtil.defaultIfNull(size, 50);
if (limit > 200) {
limit = 200;
}
List<OrderEntity> records = this.lambdaQuery()
.lt(lastId != null, OrderEntity::getId, lastId)
.orderByDesc(OrderEntity::getId)
.last("LIMIT " + limit)
.list();
return orderConvert.toPageVOList(records);
}2
3
4
5
6
7
8
9
10
11
12
13
14
大分页治理建议:
| 场景 | 建议 |
|---|---|
| 后台管理列表 | 限制最大页大小和最大 offset |
| 移动端滚动 | 使用游标分页 |
| 导出数据 | 异步导出 |
| 报表查询 | 使用汇总表 |
| 日志查询 | 限制时间范围 |
| COUNT 很慢 | 可关闭 count 或优化 count SQL |
| 深分页必须支持 | 使用延迟关联或游标方案 |
避免事务范围过大
事务范围过大会导致数据库连接占用时间长、锁持有时间长、并发能力下降。事务中不应包含远程调用、文件上传、Excel 解析、大量循环查询、MQ 发送、HTTP 调用等非数据库操作。
不推荐:
@Transactional(rollbackFor = Exception.class)
public void importUsers(MultipartFile file) {
List<UserImportExcel> rows = parseExcel(file);
uploadOriginalFile(file);
callRemoteCheckApi(rows);
saveUsers(rows);
sendMqMessage(rows);
}2
3
4
5
6
7
8
推荐拆分:
public void importUsers(MultipartFile file) {
List<UserImportExcel> rows = parseExcel(file);
Long fileId = fileUploadService.upload(file);
userImportTransactionService.saveImportData(rows, fileId);
applicationEventPublisher.publishEvent(new UserImportedEvent(fileId));
}2
3
4
5
6
7
8
事务内只保留数据库一致性相关操作:
@Transactional(rollbackFor = Exception.class)
public void saveImportData(List<UserImportExcel> rows, Long fileId) {
List<UserEntity> users = userImportConvert.toEntityList(rows);
this.saveBatch(users, 1000);
importTaskService.markSuccess(fileId, users.size());
log.info("导入用户数据入库成功,文件ID:{},数量:{}", fileId, users.size());
}2
3
4
5
6
7
8
事务范围建议:
| 操作 | 是否放事务内 |
|---|---|
| 多表写入 | 是 |
| 状态流转 | 是 |
| 库存扣减 | 是 |
| 操作日志 | 可独立事务 |
| 文件上传 | 否 |
| Excel 解析 | 否 |
| HTTP 远程调用 | 否 |
| MQ 发送 | 事务提交后 |
| 大批量处理 | 分批事务 |
| 查询报表 | 通常不需要事务 |
避免硬编码字段名
硬编码字段名容易在字段重命名后遗漏修改,也容易造成 SQL 注入风险,尤其是排序字段来自前端时。单表条件应优先使用 LambdaWrapper;前端传入排序字段必须使用白名单映射。
排序字段白名单示例:
package io.github.atengk.module.system.user.support;
import cn.hutool.core.util.StrUtil;
import java.util.Map;
/**
* 用户排序字段白名单
*
* @author Ateng
* @since 2026-05-05
*/
public final class UserSortFieldWhitelist {
private static final Map<String, String> SORT_FIELD_MAP = Map.of(
"createTime", "create_time",
"username", "username",
"status", "status"
);
private UserSortFieldWhitelist() {
}
/**
* 获取数据库排序字段
*
* @param field 前端排序字段
* @return 数据库排序字段
*/
public static String getColumn(String field) {
if (StrUtil.isBlank(field)) {
return "create_time";
}
return SORT_FIELD_MAP.getOrDefault(field, "create_time");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
XML 排序使用白名单转换后的字段:
String orderColumn = UserSortFieldWhitelist.getColumn(query.getOrderBy());
String orderDirection = StrUtil.equalsIgnoreCase(query.getOrderDirection(), "asc") ? "ASC" : "DESC";
query.setOrderColumn(orderColumn);
query.setOrderDirection(orderDirection);
ORDER BY ${query.orderColumn} ${query.orderDirection}2
3
4
5
${} 只能用于已经过白名单校验的字段名或排序方向,不能直接拼接前端原始输入。
避免硬编码建议:
| 场景 | 建议 |
|---|---|
| 查询条件字段 | LambdaWrapper |
| 更新字段 | LambdaUpdateWrapper |
| 排序字段 | 白名单 |
| 表名 | 常量或插件配置 |
| 字典类型 | 常量类 |
| 权限标识 | 常量或枚举 |
| SQL 片段 | XML <sql> 复用 |
| 前端字段映射 | 明确转换层 |
避免重复对象转换代码
DTO、Entity、VO、Excel 对象之间转换如果到处手写,会导致重复代码多、字段遗漏、维护困难。推荐统一使用 MapStruct,简单场景可用 Hutool BeanUtil,但核心业务建议使用 MapStruct 明确转换规则。
MapStruct 转换器示例:
package io.github.atengk.module.system.user.convert;
import io.github.atengk.module.system.user.dto.UserAddDTO;
import io.github.atengk.module.system.user.dto.UserUpdateDTO;
import io.github.atengk.module.system.user.entity.UserEntity;
import io.github.atengk.module.system.user.vo.UserDetailVO;
import io.github.atengk.module.system.user.vo.UserPageVO;
import org.mapstruct.*;
import java.util.List;
/**
* 用户对象转换器
*
* @author Ateng
* @since 2026-05-05
*/
@Mapper(componentModel = "spring")
public interface UserConvert {
/**
* 新增参数转实体
*
* @param dto 新增参数
* @return 用户实体
*/
UserEntity toEntity(UserAddDTO dto);
/**
* 修改参数更新实体
*
* @param dto 修改参数
* @param entity 用户实体
*/
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void updateEntity(UserUpdateDTO dto, @MappingTarget UserEntity entity);
/**
* 实体转详情返回对象
*
* @param entity 用户实体
* @return 用户详情
*/
UserDetailVO toDetailVO(UserEntity entity);
/**
* 实体转分页返回对象
*
* @param entity 用户实体
* @return 分页返回对象
*/
UserPageVO toPageVO(UserEntity entity);
/**
* 实体列表转分页返回对象列表
*
* @param entities 用户实体列表
* @return 分页返回对象列表
*/
List<UserPageVO> toPageVOList(List<UserEntity> entities);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
Hutool 适合临时、简单转换:
UserDetailVO detailVO = BeanUtil.copyProperties(entity, UserDetailVO.class);对象转换建议:
| 场景 | 推荐 |
|---|---|
| DTO 转 Entity | MapStruct |
| Entity 转 VO | MapStruct |
| 批量转换 | MapStruct |
| 少量临时转换 | Hutool BeanUtil |
| 字典回显 | 转换后统一填充 |
| 枚举标签 | MapStruct default 方法 |
| 局部更新 | MapStruct @MappingTarget |
| 大对象转换 | 避免反射高频调用 |
避免忽略并发更新
并发更新常见于用户状态、订单状态、库存扣减、审批流、余额调整等场景。如果只按 ID 更新,可能覆盖其他线程的修改。应根据业务场景选择乐观锁、条件更新、唯一约束、幂等键或分布式锁。
不推荐:
OrderEntity order = orderService.getById(orderId);
order.setOrderStatus(20);
orderService.updateById(order);2
3
推荐状态条件更新:
@Transactional(rollbackFor = Exception.class)
public void payOrder(Long orderId) {
boolean updated = this.lambdaUpdate()
.eq(OrderEntity::getId, orderId)
.eq(OrderEntity::getOrderStatus, OrderStatusEnum.WAIT_PAY.getCode())
.set(OrderEntity::getOrderStatus, OrderStatusEnum.PAID.getCode())
.set(OrderEntity::getPayTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("订单不存在或状态不允许支付");
}
log.info("订单支付状态更新成功,订单ID:{}", orderId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
库存扣减推荐条件更新:
@Transactional(rollbackFor = Exception.class)
public void deductStock(Long productId, Integer quantity) {
if (quantity == null || quantity <= 0) {
throw new BusinessException("扣减数量必须大于0");
}
boolean updated = stockService.lambdaUpdate()
.eq(StockEntity::getProductId, productId)
.ge(StockEntity::getStockCount, quantity)
.setSql("stock_count = stock_count - " + quantity)
.setSql("sold_count = sold_count + " + quantity)
.update();
if (!updated) {
throw new BusinessException("库存不足");
}
log.info("扣减库存成功,商品ID:{},数量:{}", productId, quantity);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
并发控制建议:
| 场景 | 推荐方式 |
|---|---|
| 普通编辑 | 乐观锁 |
| 订单状态流转 | 条件更新 |
| 库存扣减 | 条件更新 + 库存流水 |
| 支付回调 | 幂等键 + 状态条件 |
| 审批流 | 状态条件 + 乐观锁 |
| 唯一创建 | 唯一索引 |
| 分布式任务 | 分布式锁或任务表抢占 |
| 外部消息消费 | 消费记录表 + 唯一约束 |
避免生产环境输出完整 SQL
生产环境输出完整 SQL 会带来三个问题:日志量暴涨、敏感数据泄露、性能下降。生产环境应关闭 StdOutImpl 和 P6Spy 完整 SQL,仅保留慢 SQL 摘要、TraceId、Mapper 方法、耗时和错误信息。
不推荐生产配置:
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl2
3
推荐生产配置:
mybatis-plus:
configuration:
# 生产环境不输出完整 SQL
log-impl:
logging:
level:
root: info
io.github.atengk: info
com.baomidou.mybatisplus: warn
org.mybatis: warn2
3
4
5
6
7
8
9
10
11
慢 SQL 摘要日志示例:
log.warn("发现慢SQL,TraceId:{},Mapper:{},耗时:{}ms",
TraceIdContext.getTraceId(), mappedStatementId, costMillis);2
生产 SQL 日志建议:
| 内容 | 是否记录 |
|---|---|
| 完整 SQL | 不建议 |
| 完整参数 | 不建议 |
| Mapper 方法 | 建议 |
| SQL 类型 | 建议 |
| 执行耗时 | 建议 |
| TraceId | 建议 |
| 慢 SQL 摘要 | 建议 |
| 异常堆栈 | 系统异常记录 |
| 敏感字段 | 必须脱敏 |
最佳实践总览
MyBatis-Plus 的最佳实践可以概括为:简单 CRUD 用 MyBatis-Plus,复杂查询用 XML;Controller 不越层,Entity 不出接口;Wrapper 不拼危险 SQL;事务不包外部调用;生产不打完整 SQL;所有涉及并发、权限、租户、数据变更的操作必须有明确边界。
整体建议如下:
| 实践项 | 推荐做法 |
|---|---|
| 查询条件 | 优先 LambdaWrapper |
| 简单 CRUD | 使用 IService / ServiceImpl |
| 复杂 SQL | 使用 XML |
| 接口入参 | DTO / Query |
| 接口出参 | VO |
| 对象转换 | MapStruct 为主,BeanUtil 为辅 |
| 业务逻辑 | 放 Service |
| 数据访问 | 放 Mapper |
| 权限校验 | Service 或权限组件 |
| 数据权限 | 插件 + Service 校验 |
| 分页 | 限制 pageSize 和 offset |
| 模糊查询 | 控制范围,避免无索引大表模糊 |
| 并发更新 | 乐观锁、条件更新、唯一索引 |
| 事务 | 小范围、短事务 |
| SQL 日志 | 本地开,生产关 |
| 缓存 | 更新数据库后删缓存 |
| 异步消息 | 事务提交后发送,消费必须幂等 |
项目开发时可以把这些规则固化到代码模板、代码生成器、代码评审清单和测试用例中。单靠开发人员记忆规范不可靠,越是基础规则,越应该通过模板、封装、拦截器、测试和扫描工具约束。
项目落地示例
项目落地示例以“商品管理”为主线,串联单表 CRUD、多条件分页、多表关联查询、批量导入、数据权限、多租户、多数据源、逻辑删除、乐观锁和自动填充。示例默认使用 Spring Boot 3、MyBatis-Plus、MySQL、Lombok、Hutool、MapStruct、EasyExcel。
示例模块目录建议如下:
src/main/java/io/github/atengk/module/product
├── controller
│ └── ProductController.java
├── convert
│ └── ProductConvert.java
├── dto
│ ├── ProductAddDTO.java
│ └── ProductUpdateDTO.java
├── entity
│ ├── ProductEntity.java
│ └── ProductCategoryEntity.java
├── excel
│ └── ProductImportExcel.java
├── mapper
│ ├── ProductMapper.java
│ └── ProductCategoryMapper.java
├── query
│ └── ProductPageQuery.java
├── service
│ ├── ProductService.java
│ └── impl
│ └── ProductServiceImpl.java
└── vo
├── ProductDetailVO.java
└── ProductPageVO.java
src/main/resources/mapper/product
└── ProductMapper.xml2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
示例表结构如下,后续代码都基于这两张表展开。
-- 商品分类表
CREATE TABLE product_category (
id BIGINT NOT NULL COMMENT '分类ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父分类ID',
category_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '分类名称',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用,1启用',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
KEY idx_tenant_parent (tenant_id, parent_id),
KEY idx_tenant_status (tenant_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类表';
-- 商品表
CREATE TABLE product_info (
id BIGINT NOT NULL COMMENT '商品ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
dept_id BIGINT NOT NULL DEFAULT 0 COMMENT '归属部门ID',
category_id BIGINT NOT NULL DEFAULT 0 COMMENT '分类ID',
product_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商品编码',
product_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名称',
sale_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '销售价格',
stock_count INT NOT NULL DEFAULT 0 COMMENT '库存数量',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0下架,1上架',
remark VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
create_by BIGINT NOT NULL DEFAULT 0 COMMENT '创建人ID',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT NOT NULL DEFAULT 0 COMMENT '更新人ID',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0未删除,1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_product_code (tenant_id, product_code),
KEY idx_tenant_category (tenant_id, category_id),
KEY idx_tenant_dept (tenant_id, dept_id),
KEY idx_tenant_status_time (tenant_id, status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品信息表';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
单表 CRUD 示例
单表 CRUD 是 MyBatis-Plus 最基础的落地场景。Controller 接收 DTO 和 Query,Service 承接业务校验和事务,Mapper 只负责数据库访问,Entity 不直接暴露给前端。
商品实体用于映射 product_info 表,包含逻辑删除、乐观锁和自动填充字段。
文件位置:src/main/java/io/github/atengk/module/product/entity/ProductEntity.java
package io.github.atengk.module.product.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品实体
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
@TableName("product_info")
public class ProductEntity {
/**
* 商品ID
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 租户ID
*/
@TableField(fill = FieldFill.INSERT)
private Long tenantId;
/**
* 归属部门ID
*/
@TableField(fill = FieldFill.INSERT)
private Long deptId;
/**
* 分类ID
*/
private Long categoryId;
/**
* 商品编码
*/
private String productCode;
/**
* 商品名称
*/
private String productName;
/**
* 销售价格
*/
private BigDecimal salePrice;
/**
* 库存数量
*/
private Integer stockCount;
/**
* 状态:0下架,1上架
*/
private Integer status;
/**
* 备注
*/
private String remark;
/**
* 创建人ID
*/
@TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新人ID
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 逻辑删除标识:0未删除,1已删除
*/
@TableLogic(value = "0", delval = "1")
private Integer deleted;
/**
* 乐观锁版本号
*/
@Version
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
新增 DTO 用于接收新增商品请求,避免前端直接传 Entity。
文件位置:src/main/java/io/github/atengk/module/product/dto/ProductAddDTO.java
package io.github.atengk.module.product.dto;
import jakarta.validation.constraints.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 商品新增参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class ProductAddDTO {
/**
* 分类ID
*/
@NotNull(message = "分类ID不能为空")
private Long categoryId;
/**
* 商品编码
*/
@NotBlank(message = "商品编码不能为空")
@Size(max = 64, message = "商品编码长度不能超过64个字符")
private String productCode;
/**
* 商品名称
*/
@NotBlank(message = "商品名称不能为空")
@Size(max = 128, message = "商品名称长度不能超过128个字符")
private String productName;
/**
* 销售价格
*/
@NotNull(message = "销售价格不能为空")
@DecimalMin(value = "0.00", message = "销售价格不能小于0")
private BigDecimal salePrice;
/**
* 库存数量
*/
@NotNull(message = "库存数量不能为空")
@Min(value = 0, message = "库存数量不能小于0")
private Integer stockCount;
/**
* 状态:0下架,1上架
*/
@NotNull(message = "商品状态不能为空")
private Integer status;
/**
* 备注
*/
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
修改 DTO 增加 id 和 version,用于定位数据和乐观锁控制。
文件位置:src/main/java/io/github/atengk/module/product/dto/ProductUpdateDTO.java
package io.github.atengk.module.product.dto;
import jakarta.validation.constraints.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 商品修改参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class ProductUpdateDTO {
/**
* 商品ID
*/
@NotNull(message = "商品ID不能为空")
private Long id;
/**
* 分类ID
*/
@NotNull(message = "分类ID不能为空")
private Long categoryId;
/**
* 商品名称
*/
@NotBlank(message = "商品名称不能为空")
@Size(max = 128, message = "商品名称长度不能超过128个字符")
private String productName;
/**
* 销售价格
*/
@NotNull(message = "销售价格不能为空")
@DecimalMin(value = "0.00", message = "销售价格不能小于0")
private BigDecimal salePrice;
/**
* 库存数量
*/
@NotNull(message = "库存数量不能为空")
@Min(value = 0, message = "库存数量不能小于0")
private Integer stockCount;
/**
* 状态:0下架,1上架
*/
@NotNull(message = "商品状态不能为空")
private Integer status;
/**
* 备注
*/
@Size(max = 500, message = "备注长度不能超过500个字符")
private String remark;
/**
* 乐观锁版本号
*/
@NotNull(message = "版本号不能为空")
private Integer version;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
商品详情 VO 只返回前端需要展示的字段。
文件位置:src/main/java/io/github/atengk/module/product/vo/ProductDetailVO.java
package io.github.atengk.module.product.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品详情返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class ProductDetailVO {
/**
* 商品ID
*/
private Long id;
/**
* 分类ID
*/
private Long categoryId;
/**
* 商品编码
*/
private String productCode;
/**
* 商品名称
*/
private String productName;
/**
* 销售价格
*/
private BigDecimal salePrice;
/**
* 库存数量
*/
private Integer stockCount;
/**
* 状态
*/
private Integer status;
/**
* 状态名称
*/
private String statusName;
/**
* 备注
*/
private String remark;
/**
* 版本号
*/
private Integer version;
/**
* 创建时间
*/
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
对象转换使用 MapStruct 统一维护,避免 Service 中到处写 set。
文件位置:src/main/java/io/github/atengk/module/product/convert/ProductConvert.java
package io.github.atengk.module.product.convert;
import io.github.atengk.module.product.dto.ProductAddDTO;
import io.github.atengk.module.product.dto.ProductUpdateDTO;
import io.github.atengk.module.product.entity.ProductEntity;
import io.github.atengk.module.product.vo.ProductDetailVO;
import io.github.atengk.module.product.vo.ProductPageVO;
import org.mapstruct.*;
import java.util.List;
/**
* 商品对象转换器
*
* @author Ateng
* @since 2026-05-05
*/
@Mapper(componentModel = "spring")
public interface ProductConvert {
/**
* 新增参数转实体
*
* @param dto 新增参数
* @return 商品实体
*/
ProductEntity toEntity(ProductAddDTO dto);
/**
* 修改参数更新实体
*
* @param dto 修改参数
* @param entity 商品实体
*/
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void updateEntity(ProductUpdateDTO dto, @MappingTarget ProductEntity entity);
/**
* 实体转详情VO
*
* @param entity 商品实体
* @return 商品详情
*/
ProductDetailVO toDetailVO(ProductEntity entity);
/**
* 实体列表转分页VO列表
*
* @param entities 商品实体列表
* @return 商品分页VO列表
*/
List<ProductPageVO> toPageVOList(List<ProductEntity> entities);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
Mapper 继承 MyBatis-Plus BaseMapper,基础 CRUD 不需要额外写 SQL。
文件位置:src/main/java/io/github/atengk/module/product/mapper/ProductMapper.java
package io.github.atengk.module.product.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.atengk.module.product.entity.ProductEntity;
import io.github.atengk.module.product.query.ProductPageQuery;
import io.github.atengk.module.product.vo.ProductPageVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 商品 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface ProductMapper extends BaseMapper<ProductEntity> {
/**
* 查询商品分页列表
*
* @param query 查询参数
* @return 商品分页列表
*/
List<ProductPageVO> selectProductPageList(@Param("query") ProductPageQuery query);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Service 接口定义业务方法,不直接暴露 MyBatis-Plus 的全部底层细节给 Controller。
文件位置:src/main/java/io/github/atengk/module/product/service/ProductService.java
package io.github.atengk.module.product.service;
import com.baomidou.mybatisplus.extension.service.IService;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.product.dto.ProductAddDTO;
import io.github.atengk.module.product.dto.ProductUpdateDTO;
import io.github.atengk.module.product.entity.ProductEntity;
import io.github.atengk.module.product.query.ProductPageQuery;
import io.github.atengk.module.product.vo.ProductDetailVO;
import io.github.atengk.module.product.vo.ProductPageVO;
import java.util.List;
/**
* 商品服务
*
* @author Ateng
* @since 2026-05-05
*/
public interface ProductService extends IService<ProductEntity> {
/**
* 新增商品
*
* @param dto 新增参数
* @return 商品ID
*/
Long addProduct(ProductAddDTO dto);
/**
* 修改商品
*
* @param dto 修改参数
*/
void updateProduct(ProductUpdateDTO dto);
/**
* 删除商品
*
* @param id 商品ID
*/
void deleteProduct(Long id);
/**
* 查询商品详情
*
* @param id 商品ID
* @return 商品详情
*/
ProductDetailVO getProductDetail(Long id);
/**
* 分页查询商品
*
* @param query 查询参数
* @return 分页结果
*/
PageResult<ProductPageVO> pageProduct(ProductPageQuery query);
/**
* 批量上架商品
*
* @param ids 商品ID列表
*/
void enableProducts(List<Long> ids);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
Service 实现包含唯一性校验、事务、乐观锁、业务日志和对象转换。
文件位置:src/main/java/io/github/atengk/module/product/service/impl/ProductServiceImpl.java
package io.github.atengk.module.product.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.module.product.convert.ProductConvert;
import io.github.atengk.module.product.dto.ProductAddDTO;
import io.github.atengk.module.product.dto.ProductUpdateDTO;
import io.github.atengk.module.product.entity.ProductEntity;
import io.github.atengk.module.product.mapper.ProductMapper;
import io.github.atengk.module.product.query.ProductPageQuery;
import io.github.atengk.module.product.service.ProductService;
import io.github.atengk.module.product.vo.ProductDetailVO;
import io.github.atengk.module.product.vo.ProductPageVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 商品服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductServiceImpl extends ServiceImpl<ProductMapper, ProductEntity> implements ProductService {
private final ProductConvert productConvert;
private final CurrentUserContext currentUserContext;
/**
* 新增商品
*
* @param dto 新增参数
* @return 商品ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long addProduct(ProductAddDTO dto) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
checkProductCodeUnique(tenantId, dto.getProductCode(), null);
ProductEntity entity = productConvert.toEntity(dto);
entity.setTenantId(tenantId);
boolean saved = this.save(entity);
if (!saved) {
throw new BusinessException("新增商品失败");
}
log.info("新增商品成功,租户ID:{},商品ID:{},商品编码:{}", tenantId, entity.getId(), entity.getProductCode());
return entity.getId();
}
/**
* 修改商品
*
* @param dto 修改参数
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void updateProduct(ProductUpdateDTO dto) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
ProductEntity entity = this.lambdaQuery()
.eq(ProductEntity::getTenantId, tenantId)
.eq(ProductEntity::getId, dto.getId())
.one();
if (entity == null) {
throw new BusinessException("商品不存在");
}
productConvert.updateEntity(dto, entity);
entity.setVersion(dto.getVersion());
boolean updated = this.updateById(entity);
if (!updated) {
throw new BusinessException("商品已被修改,请刷新后重试");
}
log.info("修改商品成功,租户ID:{},商品ID:{}", tenantId, dto.getId());
}
/**
* 删除商品
*
* @param id 商品ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteProduct(Long id) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
boolean removed = this.lambdaUpdate()
.eq(ProductEntity::getTenantId, tenantId)
.eq(ProductEntity::getId, id)
.remove();
if (!removed) {
throw new BusinessException("商品不存在或已删除");
}
log.info("删除商品成功,租户ID:{},商品ID:{}", tenantId, id);
}
/**
* 查询商品详情
*
* @param id 商品ID
* @return 商品详情
*/
@Override
public ProductDetailVO getProductDetail(Long id) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
ProductEntity entity = this.lambdaQuery()
.eq(ProductEntity::getTenantId, tenantId)
.eq(ProductEntity::getId, id)
.one();
if (entity == null) {
throw new BusinessException("商品不存在");
}
ProductDetailVO detailVO = productConvert.toDetailVO(entity);
detailVO.setStatusName(ObjectUtil.equals(entity.getStatus(), 1) ? "上架" : "下架");
return detailVO;
}
/**
* 分页查询商品
*
* @param query 查询参数
* @return 分页结果
*/
@Override
public PageResult<ProductPageVO> pageProduct(ProductPageQuery query) {
return queryPageByXml(query);
}
/**
* 批量上架商品
*
* @param ids 商品ID列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void enableProducts(List<Long> ids) {
if (CollUtil.isEmpty(ids)) {
throw new BusinessException("商品ID列表不能为空");
}
Long tenantId = currentUserContext.getTenantIdOrDefault();
boolean updated = this.lambdaUpdate()
.eq(ProductEntity::getTenantId, tenantId)
.in(ProductEntity::getId, ids)
.set(ProductEntity::getStatus, 1)
.set(ProductEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("批量上架商品失败");
}
log.info("批量上架商品成功,租户ID:{},数量:{}", tenantId, ids.size());
}
/**
* 使用 XML 查询分页结果
*
* @param query 查询参数
* @return 分页结果
*/
private PageResult<ProductPageVO> queryPageByXml(ProductPageQuery query) {
query.clean();
query.setTenantId(currentUserContext.getTenantIdOrDefault());
long total = this.lambdaQuery()
.eq(ProductEntity::getTenantId, query.getTenantId())
.like(StrUtil.isNotBlank(query.getProductName()), ProductEntity::getProductName, query.getProductName())
.eq(query.getStatus() != null, ProductEntity::getStatus, query.getStatus())
.count();
if (total == 0) {
return PageResult.empty(query.getPageNum(), query.getPageSize());
}
List<ProductPageVO> records = baseMapper.selectProductPageList(query);
return PageResult.of(query.getPageNum(), query.getPageSize(), total, records);
}
/**
* 校验商品编码唯一
*
* @param tenantId 租户ID
* @param productCode 商品编码
* @param excludeId 排除商品ID
*/
private void checkProductCodeUnique(Long tenantId, String productCode, Long excludeId) {
long count = this.lambdaQuery()
.eq(ProductEntity::getTenantId, tenantId)
.eq(ProductEntity::getProductCode, productCode)
.ne(excludeId != null, ProductEntity::getId, excludeId)
.count();
if (count > 0) {
throw new BusinessException("商品编码已存在");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
Controller 只负责接口接入,不直接操作 Mapper。
文件位置:src/main/java/io/github/atengk/module/product/controller/ProductController.java
package io.github.atengk.module.product.controller;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.module.product.dto.ProductAddDTO;
import io.github.atengk.module.product.dto.ProductUpdateDTO;
import io.github.atengk.module.product.query.ProductPageQuery;
import io.github.atengk.module.product.service.ProductService;
import io.github.atengk.module.product.vo.ProductDetailVO;
import io.github.atengk.module.product.vo.ProductPageVO;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 商品接口
*
* @author Ateng
* @since 2026-05-05
*/
@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/products")
public class ProductController {
private final ProductService productService;
/**
* 新增商品
*
* @param dto 新增参数
* @return 商品ID
*/
@PostMapping
public ApiResult<Long> addProduct(@RequestBody @Valid ProductAddDTO dto) {
return ApiResult.success(productService.addProduct(dto));
}
/**
* 修改商品
*
* @param dto 修改参数
* @return 空响应
*/
@PutMapping
public ApiResult<Void> updateProduct(@RequestBody @Valid ProductUpdateDTO dto) {
productService.updateProduct(dto);
return ApiResult.success();
}
/**
* 删除商品
*
* @param id 商品ID
* @return 空响应
*/
@DeleteMapping("/{id}")
public ApiResult<Void> deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
return ApiResult.success();
}
/**
* 查询商品详情
*
* @param id 商品ID
* @return 商品详情
*/
@GetMapping("/{id}")
public ApiResult<ProductDetailVO> getProductDetail(@PathVariable Long id) {
return ApiResult.success(productService.getProductDetail(id));
}
/**
* 分页查询商品
*
* @param query 查询参数
* @return 商品分页结果
*/
@GetMapping("/page")
public ApiResult<PageResult<ProductPageVO>> pageProduct(@Valid ProductPageQuery query) {
return ApiResult.success(productService.pageProduct(query));
}
/**
* 批量上架商品
*
* @param ids 商品ID列表
* @return 空响应
*/
@PutMapping("/enable")
public ApiResult<Void> enableProducts(@RequestBody @NotEmpty(message = "商品ID列表不能为空") List<Long> ids) {
productService.enableProducts(ids);
return ApiResult.success();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
接口调用示例:
# 新增商品
curl -X POST 'http://localhost:8080/api/v1/products' \
-H 'Content-Type: application/json' \
-d '{
"categoryId": 1001,
"productCode": "P100001",
"productName": "机械键盘",
"salePrice": 299.00,
"stockCount": 100,
"status": 1,
"remark": "示例商品"
}'
# 查询详情
curl 'http://localhost:8080/api/v1/products/1001'
# 删除商品
curl -X DELETE 'http://localhost:8080/api/v1/products/1001'2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
多条件分页示例
多条件分页用于后台管理列表。分页参数必须限制页大小和最大查询深度,排序字段必须白名单处理,避免前端直接拼接 SQL 字段。
分页查询对象如下。
文件位置:src/main/java/io/github/atengk/module/product/query/ProductPageQuery.java
package io.github.atengk.module.product.query;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.exception.BusinessException;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 商品分页查询参数
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class ProductPageQuery {
/**
* 当前页
*/
@Min(value = 1, message = "当前页不能小于1")
private Long pageNum = 1L;
/**
* 每页条数
*/
@Min(value = 1, message = "每页条数不能小于1")
@Max(value = 200, message = "每页条数不能超过200")
private Long pageSize = 10L;
/**
* 租户ID,由后端上下文填充
*/
private Long tenantId;
/**
* 分类ID
*/
private Long categoryId;
/**
* 商品编码
*/
@Size(max = 64, message = "商品编码长度不能超过64个字符")
private String productCode;
/**
* 商品名称
*/
@Size(max = 128, message = "商品名称长度不能超过128个字符")
private String productName;
/**
* 最低价格
*/
private BigDecimal minPrice;
/**
* 最高价格
*/
private BigDecimal maxPrice;
/**
* 状态:0下架,1上架
*/
private Integer status;
/**
* 排序字段
*/
private String orderBy;
/**
* 排序方向
*/
private String orderDirection;
/**
* 数据偏移量
*
* @return 偏移量
*/
public Long getOffset() {
return (pageNum - 1) * pageSize;
}
/**
* 清洗和校验分页参数
*/
public void clean() {
this.productCode = StrUtil.emptyToNull(StrUtil.trim(productCode));
this.productName = StrUtil.emptyToNull(StrUtil.trim(productName));
this.orderBy = ProductSortWhitelist.toColumn(orderBy);
this.orderDirection = StrUtil.equalsIgnoreCase(orderDirection, "asc") ? "ASC" : "DESC";
if (minPrice != null && maxPrice != null && minPrice.compareTo(maxPrice) > 0) {
throw new BusinessException("最低价格不能大于最高价格");
}
if (getOffset() > 10000L) {
throw new BusinessException("分页查询过深,请缩小查询条件后重试");
}
}
/**
* 商品排序字段白名单
*
* @author Ateng
* @since 2026-05-05
*/
private static class ProductSortWhitelist {
/**
* 转换排序字段
*
* @param field 前端字段
* @return 数据库字段
*/
private static String toColumn(String field) {
if (StrUtil.isBlank(field)) {
return "p.create_time";
}
return switch (field) {
case "productCode" -> "p.product_code";
case "productName" -> "p.product_name";
case "salePrice" -> "p.sale_price";
case "stockCount" -> "p.stock_count";
case "createTime" -> "p.create_time";
default -> "p.create_time";
};
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
分页 VO 包含分类名称和状态名称,用于列表展示。
文件位置:src/main/java/io/github/atengk/module/product/vo/ProductPageVO.java
package io.github.atengk.module.product.vo;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品分页返回对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class ProductPageVO {
/**
* 商品ID
*/
private Long id;
/**
* 商品编码
*/
private String productCode;
/**
* 商品名称
*/
private String productName;
/**
* 分类ID
*/
private Long categoryId;
/**
* 分类名称
*/
private String categoryName;
/**
* 销售价格
*/
private BigDecimal salePrice;
/**
* 库存数量
*/
private Integer stockCount;
/**
* 状态
*/
private Integer status;
/**
* 状态名称
*/
private String statusName;
/**
* 版本号
*/
private Integer version;
/**
* 创建时间
*/
private LocalDateTime createTime;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
多表关联查询示例
多表关联查询适合放在 XML 中维护,不建议用 Wrapper 强行拼 JOIN。下面示例查询商品列表并关联商品分类名称。
文件位置:src/main/resources/mapper/product/ProductMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.atengk.module.product.mapper.ProductMapper">
<!-- 商品分页列表字段 -->
<sql id="ProductPageColumns">
p.id,
p.product_code,
p.product_name,
p.category_id,
c.category_name,
p.sale_price,
p.stock_count,
p.status,
CASE p.status
WHEN 1 THEN '上架'
ELSE '下架'
END AS status_name,
p.version,
p.create_time
</sql>
<!-- 商品分页查询 -->
<select id="selectProductPageList" resultType="io.github.atengk.module.product.vo.ProductPageVO">
SELECT
<include refid="ProductPageColumns"/>
FROM product_info p
LEFT JOIN product_category c
ON c.id = p.category_id
AND c.tenant_id = p.tenant_id
AND c.deleted = 0
WHERE p.tenant_id = #{query.tenantId}
AND p.deleted = 0
<if test="query.categoryId != null">
AND p.category_id = #{query.categoryId}
</if>
<if test="query.productCode != null and query.productCode != ''">
AND p.product_code = #{query.productCode}
</if>
<if test="query.productName != null and query.productName != ''">
AND p.product_name LIKE CONCAT('%', #{query.productName}, '%')
</if>
<if test="query.minPrice != null">
AND p.sale_price >= #{query.minPrice}
</if>
<if test="query.maxPrice != null">
AND p.sale_price <= #{query.maxPrice}
</if>
<if test="query.status != null">
AND p.status = #{query.status}
</if>
ORDER BY ${query.orderBy} ${query.orderDirection}
LIMIT #{query.offset}, #{query.pageSize}
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
这里使用 ${query.orderBy} 和 ${query.orderDirection} 是因为它们已经在 ProductPageQuery.clean() 中做了白名单转换,不能直接拼接前端原始值。
多表查询建议:
| 场景 | 建议 |
|---|---|
| 简单关联 | XML |
| 多条件动态查询 | XML |
| 分页 JOIN | XML + 明确 COUNT 策略 |
| 排序字段 | 白名单转换 |
| 逻辑删除 | 每张业务表都加 deleted = 0 |
| 多租户 | 每张租户表都明确租户条件 |
| 字段别名 | 必须与 VO 字段对应 |
批量导入示例
批量导入通常使用 EasyExcel 读取文件,先校验行数据,再分批入库。导入时不要在监听器中写复杂业务逻辑,监听器只负责收集批次并交给 Service。
导入行对象如下。
文件位置:src/main/java/io/github/atengk/module/product/excel/ProductImportExcel.java
package io.github.atengk.module.product.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/**
* 商品导入 Excel 行对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class ProductImportExcel {
/**
* 分类ID
*/
@ExcelProperty("分类ID")
private Long categoryId;
/**
* 商品编码
*/
@ExcelProperty("商品编码")
private String productCode;
/**
* 商品名称
*/
@ExcelProperty("商品名称")
private String productName;
/**
* 销售价格
*/
@ExcelProperty("销售价格")
private BigDecimal salePrice;
/**
* 库存数量
*/
@ExcelProperty("库存数量")
private Integer stockCount;
/**
* 状态:上架、下架
*/
@ExcelProperty("状态")
private String statusName;
/**
* 备注
*/
@ExcelProperty("备注")
private String remark;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
导入校验器负责行级参数校验。
文件位置:src/main/java/io/github/atengk/module/product/excel/ProductImportValidator.java
package io.github.atengk.module.product.excel;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.common.excel.ImportFailDetail;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 商品导入校验器
*
* @author Ateng
* @since 2026-05-05
*/
public final class ProductImportValidator {
private ProductImportValidator() {
}
/**
* 校验商品导入行
*
* @param rowIndex Excel行号
* @param row 行数据
* @return 失败明细
*/
public static List<ImportFailDetail> validate(Integer rowIndex, ProductImportExcel row) {
List<ImportFailDetail> errors = new ArrayList<>();
if (row.getCategoryId() == null) {
errors.add(new ImportFailDetail(rowIndex, "分类ID", "", "分类ID不能为空"));
}
if (StrUtil.isBlank(row.getProductCode())) {
errors.add(new ImportFailDetail(rowIndex, "商品编码", row.getProductCode(), "商品编码不能为空"));
}
if (StrUtil.isBlank(row.getProductName())) {
errors.add(new ImportFailDetail(rowIndex, "商品名称", row.getProductName(), "商品名称不能为空"));
}
if (row.getSalePrice() == null || row.getSalePrice().compareTo(BigDecimal.ZERO) < 0) {
errors.add(new ImportFailDetail(rowIndex, "销售价格", String.valueOf(row.getSalePrice()), "销售价格不能小于0"));
}
if (row.getStockCount() == null || row.getStockCount() < 0) {
errors.add(new ImportFailDetail(rowIndex, "库存数量", String.valueOf(row.getStockCount()), "库存数量不能小于0"));
}
if (!StrUtil.equalsAny(row.getStatusName(), "上架", "下架")) {
errors.add(new ImportFailDetail(rowIndex, "状态", row.getStatusName(), "状态只能是上架或下架"));
}
return errors;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
导入 Service 示例,包含文件解析、失败明细和批量保存。生产项目建议把失败明细落库,这里只展示核心流程。
package io.github.atengk.module.product.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.EasyExcel;
import io.github.atengk.common.excel.ImportFailDetail;
import io.github.atengk.common.exception.BusinessException;
import io.github.atengk.module.product.entity.ProductEntity;
import io.github.atengk.module.product.excel.ProductImportExcel;
import io.github.atengk.module.product.excel.ProductImportValidator;
import io.github.atengk.module.product.service.ProductImportService;
import io.github.atengk.module.product.service.ProductService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.*;
/**
* 商品导入服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductImportServiceImpl implements ProductImportService {
private final ProductService productService;
/**
* 导入商品
*
* @param file Excel文件
* @return 成功数量
*/
@Override
public Integer importProducts(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException("导入文件不能为空");
}
List<ProductImportExcel> rows;
try {
rows = EasyExcel.read(file.getInputStream())
.head(ProductImportExcel.class)
.sheet()
.doReadSync();
} catch (Exception exception) {
log.error("解析商品导入文件失败", exception);
throw new BusinessException("解析Excel失败,请检查模板格式");
}
if (CollUtil.isEmpty(rows)) {
throw new BusinessException("导入数据不能为空");
}
List<ImportFailDetail> errors = validateRows(rows);
if (CollUtil.isNotEmpty(errors)) {
log.warn("商品导入校验失败,失败数量:{}", errors.size());
throw new BusinessException("导入数据校验失败,请下载失败明细");
}
List<ProductEntity> entities = rows.stream()
.map(this::toEntity)
.toList();
productService.saveBatch(entities, 1000);
log.info("商品导入成功,数量:{}", entities.size());
return entities.size();
}
/**
* 校验导入行
*
* @param rows Excel行数据
* @return 失败明细
*/
private List<ImportFailDetail> validateRows(List<ProductImportExcel> rows) {
List<ImportFailDetail> errors = new ArrayList<>();
Set<String> productCodeSet = new HashSet<>();
for (int index = 0; index < rows.size(); index++) {
ProductImportExcel row = rows.get(index);
int rowIndex = index + 2;
errors.addAll(ProductImportValidator.validate(rowIndex, row));
if (StrUtil.isNotBlank(row.getProductCode()) && !productCodeSet.add(row.getProductCode())) {
errors.add(new ImportFailDetail(rowIndex, "商品编码", row.getProductCode(), "导入文件内商品编码重复"));
}
}
return errors;
}
/**
* 导入行转换为实体
*
* @param row 导入行
* @return 商品实体
*/
private ProductEntity toEntity(ProductImportExcel row) {
ProductEntity entity = new ProductEntity();
entity.setCategoryId(row.getCategoryId());
entity.setProductCode(StrUtil.trim(row.getProductCode()));
entity.setProductName(StrUtil.trim(row.getProductName()));
entity.setSalePrice(row.getSalePrice());
entity.setStockCount(row.getStockCount());
entity.setStatus(StrUtil.equals(row.getStatusName(), "上架") ? 1 : 0);
entity.setRemark(StrUtil.blankToDefault(row.getRemark(), ""));
return entity;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
批量导入接口示例:
@PostMapping("/import")
public ApiResult<Integer> importProducts(@RequestPart("file") MultipartFile file) {
return ApiResult.success(productImportService.importProducts(file));
}2
3
4
导入建议:
| 项目 | 建议 |
|---|---|
| 文件格式 | 限制 xlsx/xls |
| 行数 | 同步导入限制最大行数 |
| 重复编码 | 文件内和数据库都要校验 |
| 失败明细 | 记录行号、字段、原始值、原因 |
| 事务 | 大数据量分批事务 |
| 字典字段 | Excel 名称转数据库编码 |
| 关联字段 | 分类、部门等要校验存在 |
数据权限示例
数据权限示例基于 dept_id 控制。普通用户只能查询自己有权限的部门数据,超级管理员可以查询全部数据。真实项目可以结合 MyBatis-Plus 数据权限插件,但 Service 层仍应保留关键权限校验。
数据权限上下文示例:
package io.github.atengk.framework.security;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
/**
* 当前登录用户
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class LoginUser implements Serializable {
/**
* 用户ID
*/
private Long userId;
/**
* 租户ID
*/
private Long tenantId;
/**
* 部门ID
*/
private Long deptId;
/**
* 用户名
*/
private String username;
/**
* 是否超级管理员
*/
private Boolean superAdmin = false;
/**
* 可访问部门ID列表
*/
private List<Long> dataDeptIds = Collections.emptyList();
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
商品列表查询时追加部门权限条件。
public PageResult<ProductPageVO> pageProductWithDataScope(ProductPageQuery query) {
query.clean();
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
query.setTenantId(loginUser.getTenantId());
if (!Boolean.TRUE.equals(loginUser.getSuperAdmin())) {
if (CollUtil.isEmpty(loginUser.getDataDeptIds())) {
return PageResult.empty(query.getPageNum(), query.getPageSize());
}
query.setDeptIds(loginUser.getDataDeptIds());
}
List<ProductPageVO> records = baseMapper.selectProductPageList(query);
long total = baseMapper.selectProductPageCount(query);
return PageResult.of(query.getPageNum(), query.getPageSize(), total, records);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
对应 XML 增加部门权限条件:
<if test="query.deptIds != null and query.deptIds.size() > 0">
AND p.dept_id IN
<foreach collection="query.deptIds" item="deptId" open="(" separator="," close=")">
#{deptId}
</foreach>
</if>2
3
4
5
6
数据权限建议:
| 场景 | 建议 |
|---|---|
| 列表查询 | 自动追加部门范围 |
| 详情查询 | 校验当前用户是否有该数据权限 |
| 修改删除 | 先校验数据权限再操作 |
| 导出 | 必须受数据权限控制 |
| 超级管理员 | 可绕过数据权限 |
| 空权限范围 | 返回空结果,不查全表 |
| 插件方案 | 插件统一拦截,Service 做关键兜底 |
多租户示例
多租户示例基于 tenant_id 字段隔离数据。所有租户业务表必须包含 tenant_id,查询、修改、删除必须限制在当前租户下。
租户上下文如下。
package io.github.atengk.framework.tenant;
import cn.hutool.core.util.ObjectUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 当前租户上下文
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class CurrentTenantContext {
private static final ThreadLocal<Long> TENANT_HOLDER = new ThreadLocal<>();
/**
* 设置租户ID
*
* @param tenantId 租户ID
*/
public void setTenantId(Long tenantId) {
TENANT_HOLDER.set(tenantId);
}
/**
* 获取租户ID
*
* @return 租户ID
*/
public Long getTenantId() {
return TENANT_HOLDER.get();
}
/**
* 获取租户ID,未设置时返回0
*
* @return 租户ID
*/
public Long getTenantIdOrDefault() {
return ObjectUtil.defaultIfNull(getTenantId(), 0L);
}
/**
* 清理租户上下文
*/
public void clear() {
TENANT_HOLDER.remove();
log.trace("租户上下文已清理");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
多租户插件配置如下。
package io.github.atengk.framework.tenant;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Set;
/**
* MyBatis-Plus 多租户配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
public class TenantMyBatisPlusConfig {
/**
* 配置多租户拦截器
*
* @param currentTenantContext 当前租户上下文
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor tenantInterceptor(CurrentTenantContext currentTenantContext) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler() {
private final Set<String> ignoreTables = Set.of("sys_tenant", "sys_platform_config");
/**
* 获取租户ID表达式
*
* @return 租户ID表达式
*/
@Override
public Expression getTenantId() {
return new LongValue(currentTenantContext.getTenantIdOrDefault());
}
/**
* 获取租户字段名
*
* @return 租户字段名
*/
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
/**
* 判断是否忽略租户
*
* @param tableName 表名
* @return 是否忽略
*/
@Override
public boolean ignoreTable(String tableName) {
return ignoreTables.contains(tableName);
}
}));
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
Web 请求中设置租户上下文:
package io.github.atengk.framework.tenant;
import cn.hutool.core.convert.Convert;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 租户上下文拦截器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
@RequiredArgsConstructor
public class TenantContextInterceptor implements HandlerInterceptor {
private final CurrentTenantContext currentTenantContext;
/**
* 请求前设置租户上下文
*
* @param request 请求对象
* @param response 响应对象
* @param handler 处理器
* @return 是否继续执行
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
Long tenantId = Convert.toLong(request.getHeader("X-Tenant-Id"), 0L);
currentTenantContext.setTenantId(tenantId);
return true;
}
/**
* 请求完成后清理租户上下文
*
* @param request 请求对象
* @param response 响应对象
* @param handler 处理器
* @param ex 异常对象
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
currentTenantContext.clear();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
多租户验证方式:
# 租户1查询商品
curl 'http://localhost:8080/api/v1/products/page?pageNum=1&pageSize=10' \
-H 'X-Tenant-Id: 1'
# 租户2查询商品
curl 'http://localhost:8080/api/v1/products/page?pageNum=1&pageSize=10' \
-H 'X-Tenant-Id: 2'2
3
4
5
6
7
多数据源示例
多数据源常见于业务主库和报表库分离。业务写入走主库,统计查询走报表库。示例使用 dynamic-datasource 的 @DS 注解。
配置示例:
spring:
datasource:
dynamic:
# 默认数据源
primary: master
# 严格模式,数据源不存在时报错
strict: true
datasource:
master:
# 业务主库
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/product_master?serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: 123456
report:
# 报表库
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/product_report?serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: 1234562
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
报表 Service 使用 @DS("report") 指定报表数据源。
package io.github.atengk.module.product.service.impl;
import com.baomidou.dynamic.datasource.annotation.DS;
import io.github.atengk.module.product.mapper.ProductReportMapper;
import io.github.atengk.module.product.service.ProductReportService;
import io.github.atengk.module.product.vo.ProductSalesReportVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 商品报表服务实现
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
@DS("report")
public class ProductReportServiceImpl implements ProductReportService {
private final ProductReportMapper productReportMapper;
/**
* 查询商品销售报表
*
* @return 商品销售报表
*/
@Override
public List<ProductSalesReportVO> listSalesReport() {
List<ProductSalesReportVO> records = productReportMapper.selectSalesReport();
log.info("查询商品销售报表完成,数量:{}", records.size());
return records;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
多数据源建议:
| 场景 | 建议 |
|---|---|
| 业务写入 | master |
| 报表查询 | report |
| 读写分离 | Service 层明确切换 |
| 事务 | 避免一个本地事务跨多个数据源 |
| 注解位置 | 优先加在 Service 类或方法 |
| 自调用 | 同类内部调用不会触发 AOP |
| 分页插件 | 多数据源下确认插件都生效 |
逻辑删除示例
逻辑删除用于保留数据历史,删除接口实际执行 UPDATE deleted = 1。业务查询应默认过滤已删除数据,自定义 XML 需要手动添加 deleted = 0。
实体字段:
/**
* 逻辑删除标识:0未删除,1已删除
*/
@TableLogic(value = "0", delval = "1")
private Integer deleted;2
3
4
5
全局配置:
mybatis-plus:
global-config:
db-config:
# 全局逻辑删除字段
logic-delete-field: deleted
# 删除值
logic-delete-value: 1
# 未删除值
logic-not-delete-value: 02
3
4
5
6
7
8
9
删除 Service:
@Transactional(rollbackFor = Exception.class)
public void deleteProduct(Long id) {
Long tenantId = currentUserContext.getTenantIdOrDefault();
boolean removed = this.lambdaUpdate()
.eq(ProductEntity::getTenantId, tenantId)
.eq(ProductEntity::getId, id)
.remove();
if (!removed) {
throw new BusinessException("商品不存在或已删除");
}
log.info("逻辑删除商品成功,租户ID:{},商品ID:{}", tenantId, id);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
逻辑删除注意事项:
| 场景 | 建议 |
|---|---|
| 普通删除 | 使用逻辑删除 |
| 自定义 SQL | 手动加 deleted = 0 |
| 唯一约束 | 结合逻辑删除设计唯一键 |
| 恢复数据 | 单独提供恢复接口 |
| 物理删除 | 仅限归档清理或运维场景 |
| 关联数据 | 删除前校验引用关系 |
乐观锁示例
乐观锁用于解决并发编辑覆盖问题。详情接口返回 version,修改接口必须带回 version。如果数据已被其他人修改,更新失败并提示刷新。
实体字段:
/**
* 乐观锁版本号
*/
@Version
private Integer version;2
3
4
5
插件配置:
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 乐观锁配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
public class OptimisticLockerConfig {
/**
* 配置乐观锁插件
*
* @return MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor optimisticLockerInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
修改接口中使用版本号:
@Transactional(rollbackFor = Exception.class)
public void updateProduct(ProductUpdateDTO dto) {
ProductEntity entity = this.getById(dto.getId());
if (entity == null) {
throw new BusinessException("商品不存在");
}
productConvert.updateEntity(dto, entity);
entity.setVersion(dto.getVersion());
boolean updated = this.updateById(entity);
if (!updated) {
throw new BusinessException("商品已被其他用户修改,请刷新后重试");
}
log.info("乐观锁修改商品成功,商品ID:{},版本号:{}", dto.getId(), entity.getVersion());
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
接口调用时需要携带版本号:
curl -X PUT 'http://localhost:8080/api/v1/products' \
-H 'Content-Type: application/json' \
-d '{
"id": 1001,
"categoryId": 2001,
"productName": "机械键盘 Pro",
"salePrice": 399.00,
"stockCount": 80,
"status": 1,
"remark": "修改商品",
"version": 0
}'2
3
4
5
6
7
8
9
10
11
12
乐观锁建议:
| 场景 | 建议 |
|---|---|
| 后台编辑 | 使用乐观锁 |
| 订单状态 | 同时使用状态条件 |
| 库存扣减 | 更推荐条件更新 |
| 修改接口 | 必须传 version |
| 冲突提示 | 提示刷新后重试 |
| 批量更新 | 不适合逐条乐观锁时要单独设计 |
自动填充示例
自动填充用于统一处理 tenant_id、dept_id、create_by、create_time、update_by、update_time 等公共字段。它可以减少重复代码,但 XML 自定义 SQL 和 lambdaUpdate().set() 仍需要注意。
自动填充处理器如下。
文件位置:src/main/java/io/github/atengk/framework/mybatis/MyMetaObjectHandler.java
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 自动填充处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MyMetaObjectHandler implements MetaObjectHandler {
private final CurrentUserContext currentUserContext;
/**
* 新增时填充公共字段
*
* @param metaObject 元对象
*/
@Override
public void insertFill(MetaObject metaObject) {
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
LocalDateTime now = LocalDateTime.now();
this.strictInsertFill(metaObject, "tenantId", Long.class, loginUser.getTenantId());
this.strictInsertFill(metaObject, "deptId", Long.class, loginUser.getDeptId());
this.strictInsertFill(metaObject, "createBy", Long.class, loginUser.getUserId());
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "updateBy", Long.class, loginUser.getUserId());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
this.strictInsertFill(metaObject, "deleted", Integer.class, 0);
this.strictInsertFill(metaObject, "version", Integer.class, 0);
log.trace("新增自动填充完成,用户ID:{},租户ID:{}", loginUser.getUserId(), loginUser.getTenantId());
}
/**
* 修改时填充公共字段
*
* @param metaObject 元对象
*/
@Override
public void updateFill(MetaObject metaObject) {
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
this.strictUpdateFill(metaObject, "updateBy", Long.class, loginUser.getUserId());
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
log.trace("修改自动填充完成,用户ID:{}", loginUser.getUserId());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
新增商品时无需手动设置创建人和创建时间:
@Transactional(rollbackFor = Exception.class)
public Long addProduct(ProductAddDTO dto) {
checkProductCodeUnique(currentUserContext.getTenantIdOrDefault(), dto.getProductCode(), null);
ProductEntity entity = productConvert.toEntity(dto);
this.save(entity);
log.info("新增商品成功,商品ID:{}", entity.getId());
return entity.getId();
}2
3
4
5
6
7
8
9
10
如果使用 lambdaUpdate().set(),建议显式设置更新时间,避免自动填充不符合预期:
@Transactional(rollbackFor = Exception.class)
public void disableProduct(Long id) {
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
boolean updated = this.lambdaUpdate()
.eq(ProductEntity::getId, id)
.set(ProductEntity::getStatus, 0)
.set(ProductEntity::getUpdateBy, loginUser.getUserId())
.set(ProductEntity::getUpdateTime, LocalDateTime.now())
.update();
if (!updated) {
throw new BusinessException("下架商品失败");
}
log.info("下架商品成功,商品ID:{}", id);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
自动填充建议:
| 场景 | 建议 |
|---|---|
save | 自动填充生效 |
updateById | 自动填充通常生效 |
lambdaUpdate().set() | 建议显式 set 审计字段 |
| XML insert/update | 手动维护审计字段 |
| 定时任务 | 设置系统用户上下文 |
| MQ 消费 | 从消息恢复上下文 |
| 批量导入 | 使用导入人作为创建人 |
示例落地检查清单
这一组示例可以作为一个标准模块模板。真正落地时,建议按以下顺序检查:
| 检查项 | 是否必须 |
|---|---|
表包含 tenant_id、deleted、version | 是 |
Entity 配置 @TableId、@TableLogic、@Version | 是 |
| DTO 和 VO 与 Entity 隔离 | 是 |
| 新增、修改参数有 Validation 注解 | 是 |
| Service 做唯一性和业务校验 | 是 |
| Controller 不直接注入 Mapper | 是 |
| 多条件分页限制页大小和深分页 | 是 |
| 前端排序字段使用白名单 | 是 |
| 自定义 XML 加逻辑删除和租户条件 | 是 |
| 复杂 JOIN 不滥用 Wrapper | 是 |
| 批量导入有行级校验和失败明细 | 是 |
| 数据权限覆盖列表、详情、修改、删除 | 是 |
| 多租户上下文在异步和任务中传递 | 是 |
| 多数据源避免跨库本地事务 | 是 |
| 生产环境关闭完整 SQL | 是 |
这套示例的核心思想是:MyBatis-Plus 负责提升单表开发效率,Service 负责业务规则和事务边界,XML 负责复杂 SQL,插件负责横切能力,DTO/VO 负责接口边界。这样项目规模扩大后,代码仍然能保持清晰、可测、可维护。
进阶扩展
进阶扩展用于处理 MyBatis-Plus 默认能力无法覆盖的场景,例如复杂连表、批量通用方法、SQL 拦截、JSON 字段映射、自定义 ID、数据权限、多租户、认证授权、分库分表和数据同步。扩展能力要谨慎使用:能用标准 CRUD、XML、自带插件解决的,不要过早引入自定义底层扩展。
MyBatis-Plus Join 扩展
MyBatis-Plus Join,通常简称 MPJ,是 MyBatis-Plus 的增强工具,目标是在 MyBatis-Plus 基础上补充连表查询能力;它提供 MPJBaseMapper、MPJLambdaWrapper 以及 selectJoinList、selectJoinPage 等连表查询方法。适合中等复杂度的 JOIN 查询,但官方文档也提示过于复杂的 SQL 不推荐继续堆 Wrapper,建议使用 XML。(MyBatis-Plus-Join)
依赖示例:
<!-- MyBatis-Plus Join,用于 Lambda 风格连表查询 -->
<dependency>
<groupId>com.github.yulichang</groupId>
<artifactId>mybatis-plus-join-boot-starter</artifactId>
<version>${mybatis-plus-join.version}</version>
</dependency>2
3
4
5
6
Mapper 继承 MPJBaseMapper 后即可使用 Join 扩展能力。
文件位置:src/main/java/io/github/atengk/module/product/mapper/ProductMapper.java
package io.github.atengk.module.product.mapper;
import com.github.yulichang.base.MPJBaseMapper;
import io.github.atengk.module.product.entity.ProductEntity;
/**
* 商品 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface ProductMapper extends MPJBaseMapper<ProductEntity> {
}2
3
4
5
6
7
8
9
10
11
12
13
下面示例使用 MPJ 查询商品和分类信息。
package io.github.atengk.module.product.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import io.github.atengk.common.vo.PageResult;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.module.product.entity.ProductCategoryEntity;
import io.github.atengk.module.product.entity.ProductEntity;
import io.github.atengk.module.product.mapper.ProductMapper;
import io.github.atengk.module.product.query.ProductPageQuery;
import io.github.atengk.module.product.vo.ProductPageVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 商品 Join 查询服务
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductJoinQueryService {
private final ProductMapper productMapper;
private final CurrentUserContext currentUserContext;
/**
* 使用 MPJ 分页查询商品
*
* @param query 查询参数
* @return 分页结果
*/
public PageResult<ProductPageVO> pageProductByJoin(ProductPageQuery query) {
query.clean();
Long tenantId = currentUserContext.getTenantIdOrDefault();
Page<ProductPageVO> page = Page.of(query.getPageNum(), query.getPageSize());
MPJLambdaWrapper<ProductEntity> wrapper = new MPJLambdaWrapper<ProductEntity>()
.select(ProductEntity::getId)
.select(ProductEntity::getProductCode)
.select(ProductEntity::getProductName)
.select(ProductEntity::getCategoryId)
.select(ProductEntity::getSalePrice)
.select(ProductEntity::getStockCount)
.select(ProductEntity::getStatus)
.select(ProductEntity::getVersion)
.select(ProductEntity::getCreateTime)
.selectAs(ProductCategoryEntity::getCategoryName, ProductPageVO::getCategoryName)
.leftJoin(ProductCategoryEntity.class, ProductCategoryEntity::getId, ProductEntity::getCategoryId)
.eq(ProductEntity::getTenantId, tenantId)
.eq(ProductEntity::getDeleted, 0)
.like(StrUtil.isNotBlank(query.getProductName()), ProductEntity::getProductName, query.getProductName())
.eq(query.getStatus() != null, ProductEntity::getStatus, query.getStatus())
.orderByDesc(ProductEntity::getCreateTime);
Page<ProductPageVO> result = productMapper.selectJoinPage(page, ProductPageVO.class, wrapper);
log.info("MPJ分页查询商品完成,租户ID:{},总数:{}", tenantId, result.getTotal());
return PageResult.of(result.getCurrent(), result.getSize(), result.getTotal(), result.getRecords());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
MPJ 使用建议:
| 场景 | 建议 |
|---|---|
| 两三张表简单 JOIN | 可以使用 MPJ |
| 需要 Lambda 字段引用 | 可以使用 MPJ |
| 多层嵌套、UNION、窗口函数 | 使用 XML |
| 报表统计 SQL | 使用 XML |
| 性能敏感 SQL | 使用 XML 并明确执行计划 |
| 团队 SQL 能力强 | XML 可维护性通常更好 |
自定义 SQL 注入器
SQL 注入器用于向所有 Mapper 注入自定义通用方法。MyBatis-Plus 官方文档说明,可以通过实现 ISqlInjector 或继承 AbstractSqlInjector / DefaultSqlInjector 注入自定义 SQL 方法;默认实现是 DefaultSqlInjector。这类扩展属于底层扩展,建议只用于通用且稳定的方法,例如 insertIgnore、replaceInto、物理删除、批量插入。(MyBatis-Plus)
自定义 BaseMapper 定义通用方法。
文件位置:src/main/java/io/github/atengk/framework/mybatis/BaseExtMapper.java
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import java.util.Collection;
/**
* 扩展基础 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface BaseExtMapper<T> extends BaseMapper<T> {
/**
* MySQL 批量插入,忽略唯一键冲突
*
* @param list 实体列表
* @return 影响行数
*/
int insertIgnoreBatch(@Param("list") Collection<T> list);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
自定义 SQL 注入器示例。此类扩展依赖 MyBatis-Plus 内部 API,不同版本方法签名可能有细节差异,升级 MyBatis-Plus 后需要跑 Mapper 集成测试。
文件位置:src/main/java/io/github/atengk/framework/mybatis/ExtSqlInjector.java
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.injector.DefaultSqlInjector;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
/**
* 自定义 SQL 注入器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
public class ExtSqlInjector extends DefaultSqlInjector {
/**
* 获取注入方法列表
*
* @param mapperClass Mapper类型
* @param tableInfo 表信息
* @return 方法列表
*/
@Override
public List<AbstractMethod> getMethodList(Class<?> mapperClass, TableInfo tableInfo) {
List<AbstractMethod> methodList = super.getMethodList(mapperClass, tableInfo);
methodList.add(new InsertIgnoreBatchMethod());
log.info("注册自定义SQL方法,Mapper:{},表名:{}", mapperClass.getName(), tableInfo.getTableName());
return methodList;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
注册注入器:
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.core.injector.ISqlInjector;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus SQL 注入器配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
public class SqlInjectorConfig {
/**
* 注册自定义 SQL 注入器
*
* @return SQL 注入器
*/
@Bean
public ISqlInjector sqlInjector() {
return new ExtSqlInjector();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
SQL 注入器建议:
| 场景 | 是否建议 |
|---|---|
| 通用批量插入 | 可以 |
MySQL insert ignore | 可以 |
MySQL replace into | 谨慎 |
| 复杂业务 SQL | 不建议 |
| 多表 JOIN | 不建议 |
| 报表 SQL | 不建议 |
| 数据权限 | 优先用插件或 XML |
| 特定业务方法 | 放 Mapper XML |
自定义通用方法
自定义通用方法通常配合自定义 BaseMapper 和 SQL 注入器使用。它适合封装所有 Mapper 都可能使用的能力,但不适合封装带业务语义的方法。
使用示例:
package io.github.atengk.module.product.mapper;
import io.github.atengk.framework.mybatis.BaseExtMapper;
import io.github.atengk.module.product.entity.ProductEntity;
/**
* 商品 Mapper
*
* @author Ateng
* @since 2026-05-05
*/
public interface ProductMapper extends BaseExtMapper<ProductEntity> {
}2
3
4
5
6
7
8
9
10
11
12
13
Service 调用通用方法:
@Transactional(rollbackFor = Exception.class)
public int importProductsIgnoreDuplicate(List<ProductEntity> products) {
if (CollUtil.isEmpty(products)) {
return 0;
}
int count = baseMapper.insertIgnoreBatch(products);
log.info("忽略重复批量导入商品完成,提交数量:{},入库数量:{}", products.size(), count);
return count;
}2
3
4
5
6
7
8
9
10
通用方法设计建议:
| 方法 | 建议 |
|---|---|
insertIgnoreBatch | 可以封装 |
physicalDeleteById | 可以封装,但限制权限 |
selectByBizCode | 不建议,业务差异大 |
updateStatus | 不建议,状态语义不同 |
deleteAll | 极其谨慎 |
truncate | 禁止放业务 Mapper |
upsertBatch | 谨慎,明确数据库方言 |
自定义拦截器
MyBatis-Plus 从 3.4.0 起使用 MybatisPlusInterceptor 作为插件主体,官方插件基于 InnerInterceptor 扩展;多个插件共存时要注意顺序,官方建议对 SQL 做单次改造的插件优先,不改造 SQL 的插件放后面。(MyBatis-Plus)
自定义拦截器可以用于审计 SQL、阻断危险 SQL、记录耗时、限制全表更新等。下面示例拦截没有 WHERE 条件的 UPDATE / DELETE。
文件位置:src/main/java/io/github/atengk/framework/mybatis/SafeSqlInnerInterceptor.java
package io.github.atengk.framework.mybatis;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.statement.StatementHandler;
import java.sql.Connection;
/**
* 安全 SQL 拦截器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
public class SafeSqlInnerInterceptor implements InnerInterceptor {
/**
* SQL 预处理前检查危险 SQL
*
* @param sh StatementHandler
* @param connection 数据库连接
* @param transactionTimeout 事务超时时间
*/
@Override
public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
String sql = StrUtil.cleanBlank(sh.getBoundSql().getSql()).toLowerCase();
boolean dangerUpdate = StrUtil.startWith(sql, "update") && !StrUtil.contains(sql, "where");
boolean dangerDelete = StrUtil.startWith(sql, "delete") && !StrUtil.contains(sql, "where");
if (dangerUpdate || dangerDelete) {
log.error("拦截危险SQL:{}", sql);
throw new IllegalStateException("禁止执行无 WHERE 条件的 UPDATE 或 DELETE");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
注册拦截器:
package io.github.atengk.framework.mybatis;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MyBatis-Plus 插件配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
public class MyBatisPlusInterceptorConfig {
/**
* 配置 MyBatis-Plus 插件
*
* @return 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 安全检查类插件通常放在后面
interceptor.addInnerInterceptor(new SafeSqlInnerInterceptor());
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
拦截器建议:
| 场景 | 建议 |
|---|---|
| SQL 耗时监控 | 可以 |
| 危险 SQL 阻断 | 可以 |
| 全局数据改写 | 谨慎 |
| 复杂业务判断 | 不建议 |
| 权限控制 | 优先数据权限插件 |
| 参数脱敏 | 可在日志层处理 |
| 插件顺序 | 必须测试 |
自定义 TypeHandler
TypeHandler 用于 Java 类型和 JDBC 类型之间的转换。MyBatis-Plus 官方文档说明,TypeHandler 可以通过 @TableField 注解快速注入,MyBatis-Plus 也提供了 JSON 字段相关的内置处理器,例如 JacksonTypeHandler、Fastjson2TypeHandler 等。(MyBatis-Plus)
如果内置 JSON TypeHandler 无法满足需求,可以自定义处理器。下面示例把商品规格列表保存为 JSON。
文件位置:src/main/java/io/github/atengk/module/product/vo/ProductSpecVO.java
package io.github.atengk.module.product.vo;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* 商品规格对象
*
* @author Ateng
* @since 2026-05-05
*/
@Getter
@Setter
public class ProductSpecVO implements Serializable {
/**
* 规格名称
*/
private String specName;
/**
* 规格值
*/
private String specValue;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
自定义 TypeHandler:
文件位置:src/main/java/io/github/atengk/framework/mybatis/ProductSpecListTypeHandler.java
package io.github.atengk.framework.mybatis;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.atengk.module.product.vo.ProductSpecVO;
import lombok.SneakyThrows;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.*;
import java.util.List;
/**
* 商品规格列表 TypeHandler
*
* @author Ateng
* @since 2026-05-05
*/
public class ProductSpecListTypeHandler extends BaseTypeHandler<List<ProductSpecVO>> {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* 设置非空参数
*
* @param ps PreparedStatement
* @param i 参数位置
* @param parameter 参数值
* @param jdbcType JDBC类型
*/
@Override
@SneakyThrows
public void setNonNullParameter(PreparedStatement ps, int i, List<ProductSpecVO> parameter, JdbcType jdbcType) {
ps.setString(i, CollUtil.isEmpty(parameter) ? "[]" : OBJECT_MAPPER.writeValueAsString(parameter));
}
/**
* 根据列名获取结果
*
* @param rs ResultSet
* @param columnName 列名
* @return 商品规格列表
*/
@Override
@SneakyThrows
public List<ProductSpecVO> getNullableResult(ResultSet rs, String columnName) {
return parse(rs.getString(columnName));
}
/**
* 根据列位置获取结果
*
* @param rs ResultSet
* @param columnIndex 列位置
* @return 商品规格列表
*/
@Override
@SneakyThrows
public List<ProductSpecVO> getNullableResult(ResultSet rs, int columnIndex) {
return parse(rs.getString(columnIndex));
}
/**
* 从存储过程结果获取数据
*
* @param cs CallableStatement
* @param columnIndex 列位置
* @return 商品规格列表
*/
@Override
@SneakyThrows
public List<ProductSpecVO> getNullableResult(CallableStatement cs, int columnIndex) {
return parse(cs.getString(columnIndex));
}
/**
* 解析 JSON
*
* @param json JSON文本
* @return 商品规格列表
*/
private List<ProductSpecVO> parse(String json) throws Exception {
if (StrUtil.isBlank(json)) {
return List.of();
}
return OBJECT_MAPPER.readValue(json, new TypeReference<List<ProductSpecVO>>() {
});
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
实体字段启用 TypeHandler 时,@TableName 需要开启 autoResultMap。
@TableName(value = "product_info", autoResultMap = true)
public class ProductEntity {
/**
* 商品规格 JSON
*/
@TableField(typeHandler = ProductSpecListTypeHandler.class)
private List<ProductSpecVO> specs;
}2
3
4
5
6
7
8
9
10
自定义 ID 生成器
自定义 ID 生成器用于替换默认雪花 ID,例如需要带业务前缀、接入企业统一发号器、按机房分段、对接 Redis/Leaf/号段模式等。MyBatis-Plus 可通过 IdentifierGenerator 扩展 ID 生成逻辑。
下面示例演示自定义 Long ID 生成器。实际生产建议接入稳定的号段服务或统一 ID 服务,不建议每个应用各自随意生成。
文件位置:src/main/java/io/github/atengk/framework/mybatis/BizIdentifierGenerator.java
package io.github.atengk.framework.mybatis;
import cn.hutool.core.lang.Snowflake;
import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 业务 ID 生成器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
public class BizIdentifierGenerator implements IdentifierGenerator {
private final Snowflake snowflake = IdUtil.getSnowflake(1, 1);
/**
* 生成 Long 类型 ID
*
* @param entity 实体对象
* @return ID
*/
@Override
public Number nextId(Object entity) {
long id = snowflake.nextId();
log.trace("生成业务ID:{}", id);
return id;
}
/**
* 生成字符串 ID
*
* @param entity 实体对象
* @return 字符串ID
*/
@Override
public String nextUUID(Object entity) {
return IdUtil.fastSimpleUUID();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
ID 生成建议:
| 场景 | 建议 |
|---|---|
| 普通业务主键 | ASSIGN_ID 足够 |
| 业务单号 | 单独生成,不直接用主键 |
| 分库分表 | ID 中考虑分片键 |
| 多机部署 | workerId/datacenterId 不能冲突 |
| 极高并发 | 使用号段模式 |
| 可读订单号 | 用业务单号生成器,不用主键 |
自定义数据权限处理器
数据权限插件会在 SQL 执行前追加权限 SQL 片段;官方文档说明 DataPermissionInterceptor 可通过动态拼接权限 SQL 片段限制用户数据访问,核心扩展点包括 MultiDataPermissionHandler。(MyBatis-Plus)
下面示例按部门范围追加 dept_id IN (...) 条件。
文件位置:src/main/java/io/github/atengk/framework/security/DeptDataPermissionHandler.java
package io.github.atengk.framework.security;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.handler.MultiDataPermissionHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Table;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 部门数据权限处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DeptDataPermissionHandler implements MultiDataPermissionHandler {
private static final Set<String> DATA_SCOPE_TABLES = Set.of(
"product_info",
"biz_order",
"sys_user"
);
private final CurrentUserContext currentUserContext;
/**
* 获取数据权限 SQL 片段
*
* @param table 表信息
* @param where 原始 WHERE
* @param mappedStatementId Mapper方法ID
* @return 权限 SQL 片段
*/
@Override
public Expression getSqlSegment(Table table, Expression where, String mappedStatementId) {
String tableName = table.getName();
if (!DATA_SCOPE_TABLES.contains(tableName)) {
return null;
}
LoginUser loginUser = currentUserContext.getLoginUserOrDefault();
if (Boolean.TRUE.equals(loginUser.getSuperAdmin())) {
return null;
}
List<Long> deptIds = loginUser.getDataDeptIds();
if (CollUtil.isEmpty(deptIds)) {
return parseExpression("1 = 0");
}
String alias = StrUtil.blankToDefault(table.getAlias() == null ? null : table.getAlias().getName(), tableName);
String deptIdText = deptIds.stream().map(String::valueOf).collect(Collectors.joining(","));
return parseExpression(alias + ".dept_id IN (" + deptIdText + ")");
}
/**
* 解析 SQL 表达式
*
* @param expression SQL表达式文本
* @return SQL表达式
*/
private Expression parseExpression(String expression) {
try {
return CCJSqlParserUtil.parseCondExpression(expression);
} catch (Exception exception) {
log.error("解析数据权限SQL失败,表达式:{}", expression, exception);
throw new IllegalStateException("解析数据权限SQL失败");
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
注册数据权限插件:
@Bean
public MybatisPlusInterceptor dataPermissionInterceptor(DeptDataPermissionHandler handler) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor(handler));
return interceptor;
}2
3
4
5
6
数据权限扩展建议:
| 场景 | 建议 |
|---|---|
| 表级数据权限 | 插件处理 |
| 详情/修改/删除权限 | Service 层兜底校验 |
| 超级管理员 | 返回 null 不追加条件 |
| 空权限 | 返回 1 = 0 |
| 复杂 JOIN | 明确表别名 |
| 导出接口 | 必须经过数据权限 |
| 插件忽略 | 必须代码评审 |
自定义租户处理器
租户处理器用于为 SQL 自动追加 tenant_id 条件。MyBatis-Plus 多租户插件的核心扩展点是 TenantLineHandler,可提供租户 ID、租户字段和忽略表规则。
文件位置:src/main/java/io/github/atengk/framework/tenant/BizTenantLineHandler.java
package io.github.atengk.framework.tenant;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import lombok.RequiredArgsConstructor;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* 业务租户处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Component
@RequiredArgsConstructor
public class BizTenantLineHandler implements TenantLineHandler {
private static final Set<String> IGNORE_TABLES = Set.of(
"sys_tenant",
"sys_platform_config",
"flyway_schema_history"
);
private final CurrentTenantContext currentTenantContext;
/**
* 获取租户ID表达式
*
* @return 租户ID表达式
*/
@Override
public Expression getTenantId() {
return new LongValue(currentTenantContext.getTenantIdOrDefault());
}
/**
* 获取租户字段名
*
* @return 租户字段名
*/
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
/**
* 判断是否忽略租户
*
* @param tableName 表名
* @return 是否忽略
*/
@Override
public boolean ignoreTable(String tableName) {
return CollUtil.contains(IGNORE_TABLES, tableName);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
插件注册:
@Bean
public MybatisPlusInterceptor tenantLineInterceptor(BizTenantLineHandler handler) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(handler));
return interceptor;
}2
3
4
5
6
租户扩展建议:
| 场景 | 建议 |
|---|---|
| 所有业务表 | 必须有 tenant_id |
| 平台表 | 配置忽略 |
| 迁移历史表 | 配置忽略 |
| 登录前接口 | 明确租户来源 |
| 定时任务 | 遍历租户设置上下文 |
| 异步任务 | 传递租户上下文 |
| XML SQL | 注意插件是否能解析 |
自定义代码生成模板
MyBatis-Plus 代码生成器支持自定义模板,适合统一生成 Entity、Mapper、Service、Controller、DTO、VO、Query。MyBatis-Plus 官方能力包括代码生成器和自定义模板支持,常见模板引擎包括 Freemarker、Velocity。(MyBatis-Plus)
生成器依赖示例:
<!-- MyBatis-Plus 代码生成器 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>${mybatis-plus-generator.version}</version>
</dependency>
<!-- Freemarker 模板引擎 -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>${freemarker.version}</version>
</dependency>2
3
4
5
6
7
8
9
10
11
12
13
代码生成器示例:
package io.github.atengk.generator;
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
/**
* 商品模块代码生成器
*
* @author Ateng
* @since 2026-05-05
*/
public class ProductCodeGenerator {
/**
* 执行代码生成
*
* @param args 启动参数
*/
public static void main(String[] args) {
FastAutoGenerator.create(
"jdbc:mysql://127.0.0.1:3306/mybatis_plus_demo?serverTimezone=Asia/Shanghai&useSSL=false",
"root",
"123456"
)
.globalConfig(builder -> builder
.author("Ateng")
.outputDir(System.getProperty("user.dir") + "/src/main/java")
.commentDate("yyyy-MM-dd")
)
.packageConfig(builder -> builder
.parent("io.github.atengk.module")
.moduleName("product")
)
.strategyConfig(builder -> builder
.addInclude("product_info")
.entityBuilder()
.enableLombok()
.enableTableFieldAnnotation()
.controllerBuilder()
.enableRestStyle()
.serviceBuilder()
.formatServiceFileName("%sService")
)
.templateEngine(new FreemarkerTemplateEngine())
.execute();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
模板建议:
| 模板 | 建议 |
|---|---|
| Entity | 生成基础字段和注解 |
| Mapper | 继承统一 BaseMapper |
| Service | 生成接口和实现 |
| Controller | 生成 REST 风格 |
| DTO/VO/Query | 建议自定义模板生成 |
| XML | 生成基础 ResultMap |
| 注释 | 统一 @author Ateng |
| 生成后 | 必须人工审查,不直接提交 |
与 Sa-Token 集成
Sa-Token 可用于登录认证、权限校验、角色校验和会话管理。Maven Central 上 sa-token-spring-boot3-starter 的描述是 “springboot3 integrate sa-token”,Spring Boot 3 项目应使用 Boot3 starter。(Maven Central)
依赖示例:
<!-- Sa-Token Spring Boot 3 Starter,用于登录认证和权限校验 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>${sa-token.version}</version>
</dependency>2
3
4
5
6
配置示例:
sa-token:
# Token 名称
token-name: Authorization
# Token 有效期,单位秒
timeout: 7200
# 是否允许同一账号多地登录
is-concurrent: true
# Token 前缀
token-prefix: Bearer
# 是否输出操作日志
is-log: false2
3
4
5
6
7
8
9
10
11
Sa-Token 权限接口实现:
package io.github.atengk.framework.security.satoken;
import cn.dev33.satoken.stp.StpInterface;
import io.github.atengk.framework.security.UserPermissionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Sa-Token 权限实现
*
* @author Ateng
* @since 2026-05-05
*/
@Component
@RequiredArgsConstructor
public class SaTokenPermissionImpl implements StpInterface {
private final UserPermissionService userPermissionService;
/**
* 获取权限码集合
*
* @param loginId 登录ID
* @param loginType 登录类型
* @return 权限码集合
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return userPermissionService.listPermissionCodes(Long.valueOf(String.valueOf(loginId)));
}
/**
* 获取角色码集合
*
* @param loginId 登录ID
* @param loginType 登录类型
* @return 角色码集合
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
return userPermissionService.listRoleCodes(Long.valueOf(String.valueOf(loginId)));
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
登录接口示例:
package io.github.atengk.module.auth.controller;
import cn.dev33.satoken.stp.StpUtil;
import io.github.atengk.common.result.ApiResult;
import io.github.atengk.module.auth.dto.LoginDTO;
import io.github.atengk.module.auth.service.AuthService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 认证接口
*
* @author Ateng
* @since 2026-05-05
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthService authService;
/**
* 登录
*
* @param dto 登录参数
* @return Token
*/
@PostMapping("/login")
public ApiResult<String> login(@RequestBody @Valid LoginDTO dto) {
Long userId = authService.checkLogin(dto);
StpUtil.login(userId);
return ApiResult.success(StpUtil.getTokenValue());
}
/**
* 退出登录
*
* @return 空响应
*/
@PostMapping("/logout")
public ApiResult<Void> logout() {
StpUtil.logout();
return ApiResult.success();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
Sa-Token 集成建议:
| 场景 | 建议 |
|---|---|
| 单体后台 | Sa-Token 简洁 |
| 权限码校验 | 使用 StpUtil.checkPermission |
| 角色校验 | 使用 StpUtil.checkRole |
| 用户上下文 | 登录后从 Token 解析用户 |
| 多租户 | Token 中或缓存中保存 tenantId |
| 权限缓存 | 角色、权限变更后清理 |
| 网关鉴权 | 需要统一 Token 解析策略 |
与 Spring Security 集成
Spring Security 适合需要标准安全过滤链、OAuth2 Resource Server、JWT、方法级权限、细粒度授权的项目。Spring Security 6 使用 SecurityFilterChain Bean 风格配置,请求授权使用 authorizeHttpRequests;官方文档说明默认请求需要认证,授权规则需要在 HttpSecurity 中声明。(Home)
依赖示例:
<!-- Spring Security,用于认证授权 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>2
3
4
5
安全配置示例:
package io.github.atengk.framework.security.spring;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Spring Security 配置
*
* @author Ateng
* @since 2026-05-05
*/
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
/**
* 配置安全过滤链
*
* @param http HttpSecurity
* @return 安全过滤链
* @throws Exception 配置异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/v1/auth/login", "/actuator/health").permitAll()
.requestMatchers("/api/v1/admin/**").hasAuthority("system:admin")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
JWT 过滤器示例:
package io.github.atengk.framework.security.spring;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.framework.security.CurrentUserContext;
import io.github.atengk.framework.security.LoginUser;
import io.github.atengk.framework.security.TokenService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* JWT 认证过滤器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenService tokenService;
private final CurrentUserContext currentUserContext;
/**
* 过滤请求并解析登录用户
*
* @param request 请求
* @param response 响应
* @param filterChain 过滤链
* @throws ServletException Servlet异常
* @throws IOException IO异常
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String token = StrUtil.removePrefix(request.getHeader("Authorization"), "Bearer ");
if (StrUtil.isNotBlank(token)) {
LoginUser loginUser = tokenService.parseToken(token);
currentUserContext.setLoginUser(loginUser);
tokenService.setAuthentication(loginUser);
}
filterChain.doFilter(request, response);
} finally {
currentUserContext.clear();
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
Spring Security 集成建议:
| 场景 | 建议 |
|---|---|
| 标准企业安全 | Spring Security |
| OAuth2/JWT | Spring Security 更合适 |
| 简单后台权限 | Sa-Token 更轻量 |
| 方法级权限 | @PreAuthorize |
| 用户上下文 | 自定义 Filter 设置 |
| 数据权限 | 从 SecurityContext 或当前用户上下文读取 |
| 异常响应 | 自定义认证失败和拒绝访问处理器 |
与 ShardingSphere 集成
ShardingSphere-JDBC 可用于分库分表、读写分离、分布式主键等场景。官方文档说明 ShardingSphere-JDBC 提供 Spring Boot Starter,可通过 Spring Boot 配置数据源映射、规则和属性,并作为 DataSource 注入给 ORM 框架使用。(Apache ShardingSphere)
依赖示例:
<!-- ShardingSphere-JDBC Spring Boot Starter,用于分库分表或读写分离 -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>${shardingsphere.version}</version>
</dependency>2
3
4
5
6
按订单 ID 分表配置示例:
spring:
shardingsphere:
datasource:
names: ds0
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3306/order_db?serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: 123456
rules:
sharding:
tables:
biz_order:
# 订单表按后缀分为 4 张表
actual-data-nodes: ds0.biz_order_$->{0..3}
table-strategy:
standard:
sharding-column: id
sharding-algorithm-name: order-inline
sharding-algorithms:
order-inline:
type: INLINE
props:
algorithm-expression: biz_order_$->{id % 4}
props:
# 开发调试可打开,生产关闭
sql-show: false2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
ShardingSphere 与 MyBatis-Plus 集成建议:
| 场景 | 建议 |
|---|---|
| 分表键 | 查询必须带分片键 |
| 主键 | 使用全局唯一 ID |
| 分页 | 跨分片分页成本高 |
| JOIN | 避免跨库 JOIN |
| 事务 | 明确本地事务和分布式事务边界 |
| MyBatis-Plus 插件 | 测试租户、分页、动态表名与分片规则冲突 |
| 报表 | 建议走汇总表或数仓 |
与 Canal 集成
Canal 常用于基于 MySQL Binlog 做数据同步、缓存刷新、搜索索引同步、异构数据同步。Canal 官方 QuickStart 说明,自建 MySQL 需要开启 binlog,并配置 binlog-format=ROW;Canal 账号需要具备复制相关权限。(GitHub)
MySQL 配置示例:
[mysqld]
# 开启 binlog
log-bin=mysql-bin
# Canal 推荐 ROW 模式
binlog-format=ROW
# MySQL 复制需要 server_id
server_id=12
3
4
5
6
7
8
9
授权示例:
-- 创建 Canal 账号
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_password';
-- 授权复制权限
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
-- 刷新权限
FLUSH PRIVILEGES;2
3
4
5
6
7
8
Canal 消息处理示例。真实项目通常会接入 Canal Client、Canal Adapter 或 MQ,这里展示消费到变更事件后的缓存清理逻辑。
文件位置:src/main/java/io/github/atengk/framework/canal/ProductCanalEventHandler.java
package io.github.atengk.framework.canal;
import cn.hutool.core.util.StrUtil;
import io.github.atengk.framework.redis.RedisCacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 商品 Canal 事件处理器
*
* @author Ateng
* @since 2026-05-05
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductCanalEventHandler {
private final RedisCacheService redisCacheService;
/**
* 处理商品变更事件
*
* @param eventType 事件类型
* @param afterData 变更后数据
*/
public void handleProductChanged(String eventType, Map<String, String> afterData) {
String productId = afterData.get("id");
String tenantId = afterData.get("tenant_id");
if (StrUtil.isBlank(productId) || StrUtil.isBlank(tenantId)) {
log.warn("忽略无效商品变更事件,事件类型:{},数据:{}", eventType, afterData);
return;
}
String cacheKey = StrUtil.format("product:detail:{}:{}", tenantId, productId);
redisCacheService.delete(cacheKey);
log.info("处理商品Binlog变更完成,事件类型:{},缓存Key:{}", eventType, cacheKey);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Canal 集成建议:
| 场景 | 建议 |
|---|---|
| 缓存失效 | 适合 |
| 搜索索引同步 | 适合 |
| 数据同步到 ES | 适合 |
| 审计日志 | 可用,但业务日志仍需保留 |
| 强一致交易 | 不要依赖 Canal |
| DDL 变更 | 需要单独处理 |
| 消息幂等 | 按 binlog 位点或业务主键幂等 |
| 延迟 | 接受最终一致 |
进阶扩展整体建议
进阶扩展的优先级应该从“简单、标准、可维护”到“底层、强扩展、强约束”。不要为了少写几行 SQL 就引入 SQL 注入器,也不要为了简单 JOIN 放弃 XML 的可读性。
| 扩展点 | 使用建议 |
|---|---|
| MPJ | 中等复杂度 JOIN |
| SQL 注入器 | 少量全局通用方法 |
| 通用方法 | 只封装稳定技术能力 |
| 自定义拦截器 | SQL 安全、耗时、审计 |
| TypeHandler | JSON、加密字段、自定义类型 |
| ID 生成器 | 统一 ID 体系 |
| 数据权限处理器 | 表级权限过滤 |
| 租户处理器 | 租户隔离 |
| 代码生成模板 | 统一工程规范 |
| Sa-Token | 轻量后台认证 |
| Spring Security | 标准安全体系、OAuth2/JWT |
| ShardingSphere | 分库分表、读写分离 |
| Canal | Binlog 同步、缓存失效、索引同步 |
落地顺序建议是:先用 MyBatis-Plus 标准能力和 XML 解决 80% 场景;再引入权限、租户、缓存、日志等横切能力;最后再考虑 SQL 注入器、ShardingSphere、Canal 这类底层或架构级扩展。这样项目复杂度会更可控。
项目规范总结
项目规范总结用于把 Spring Boot 3 + MyBatis-Plus 项目的分层、命名、注解、SQL、事务、异常、日志、测试、性能和安全规则固化下来。规范的目标不是限制开发效率,而是减少越层调用、SQL 风险、事务失效、数据泄露、性能退化和维护成本。
分层职责规范
分层职责要保持清晰。Controller 不写业务,Service 不拼复杂 SQL,Mapper 不承载业务规则,Entity 不直接作为接口协议暴露给前端。
| 层级 | 主要职责 | 禁止事项 |
|---|---|---|
| Controller | 接收请求、参数校验、调用 Service、返回统一响应 | 直接调用 Mapper、写事务、写复杂业务 |
| Service | 业务校验、事务控制、权限校验、缓存处理、日志记录 | 拼接复杂 SQL、直接返回 Entity 给前端 |
| Mapper | 数据库访问、单表 CRUD、自定义 SQL 调用 | 写业务规则、写权限判断、写缓存逻辑 |
| XML | 复杂 SQL、多表 JOIN、报表统计、动态 SQL | 拼接未校验的前端参数 |
| Entity | 表结构映射 | 直接作为接口入参或出参 |
| DTO | 新增、修改、导入等接口入参 | 混入数据库审计字段 |
| Query | 查询条件、分页参数 | 直接拼接 SQL 字段 |
| VO | 接口返回对象 | 暴露密码、删除标识、内部字段 |
| Convert | 对象转换 | 写数据库查询或业务规则 |
| Config | 框架配置、插件配置 | 写业务逻辑 |
推荐调用链如下:
Controller -> Service -> Mapper -> Database
Controller -> Service -> Convert -> VO
Service -> Cache / MQ / Log / Permission2
3
不推荐调用链如下:
Controller -> Mapper
Controller -> Entity
Mapper -> Service
Entity -> Service
VO getter -> Database2
3
4
5
分层落地要求:
| 规则 | 要求 |
|---|---|
| 接口层 | 只处理协议,不处理业务 |
| 业务层 | 所有业务规则必须进入 Service |
| 数据层 | Mapper 只处理数据访问 |
| 转换层 | DTO、Entity、VO 转换集中管理 |
| 权限 | 查询、详情、修改、删除都要校验 |
| 事务 | 放在 Service 层 |
| 日志 | 关键业务操作在 Service 或切面记录 |
命名规范
命名要稳定、清晰、可搜索。数据库字段使用下划线,Java 属性使用小驼峰,类名使用业务名加后缀,接口路径使用复数资源名。
| 类型 | 命名示例 | 说明 |
|---|---|---|
| 表名 | sys_user、product_info | 小写下划线 |
| 字段名 | create_time、tenant_id | 小写下划线 |
| Entity | UserEntity | 表结构实体 |
| Mapper | UserMapper | 数据访问接口 |
| Service | UserService | 业务接口 |
| ServiceImpl | UserServiceImpl | 业务实现 |
| Controller | UserController | 接口控制器 |
| AddDTO | UserAddDTO | 新增入参 |
| UpdateDTO | UserUpdateDTO | 修改入参 |
| Query | UserPageQuery | 查询入参 |
| PageVO | UserPageVO | 分页返回 |
| DetailVO | UserDetailVO | 详情返回 |
| Convert | UserConvert | 对象转换 |
| Enum | UserStatusEnum | 枚举 |
| Constant | CacheKeys、DictTypes | 常量类 |
接口路径建议:
| 操作 | HTTP 方法 | 路径 |
|---|---|---|
| 新增 | POST | /api/v1/users |
| 修改 | PUT | /api/v1/users |
| 删除 | DELETE | /api/v1/users/{id} |
| 批量删除 | DELETE | /api/v1/users/batch |
| 详情 | GET | /api/v1/users/{id} |
| 分页 | GET | /api/v1/users/page |
| 导入 | POST | /api/v1/users/import |
| 导出 | GET | /api/v1/users/export |
命名约束:
| 场景 | 规范 |
|---|---|
| 状态字段 | status |
| 排序字段 | sort_order |
| 逻辑删除 | deleted |
| 乐观锁 | version |
| 租户字段 | tenant_id |
| 创建人 | create_by |
| 创建时间 | create_time |
| 更新人 | update_by |
| 更新时间 | update_time |
| 业务编码 | xxx_code |
| 业务名称 | xxx_name |
注解使用规范
注解要表达清晰的框架语义,不能滥用。实体注解用于表映射,校验注解用于入参校验,事务注解用于业务边界,权限注解用于接口或方法授权。
| 注解 | 使用位置 | 说明 |
|---|---|---|
@RestController | Controller | REST 接口 |
@RequestMapping | Controller | 统一接口前缀 |
@Validated | Controller 类 | 启用方法参数校验 |
@Valid | 请求体参数 | 校验 DTO |
@Service | ServiceImpl | 业务实现 |
@Transactional | Service 方法 | 事务边界 |
@MapperScan | 启动类或配置类 | 扫描 Mapper |
@TableName | Entity | 表名映射 |
@TableId | Entity 主键 | 主键策略 |
@TableField | Entity 字段 | 字段映射和自动填充 |
@TableLogic | Entity 字段 | 逻辑删除 |
@Version | Entity 字段 | 乐观锁 |
@EnumValue | 枚举字段 | 枚举入库值 |
@JsonValue | 枚举字段 | 枚举接口序列化 |
@Param | Mapper 方法参数 | XML 参数命名 |
注解使用要求:
| 场景 | 规范 |
|---|---|
| Controller 入参 | 必须使用 Validation |
| 修改接口 | DTO 必须包含 id,并建议包含 version |
| Entity 主键 | 必须显式 @TableId |
| 逻辑删除字段 | 使用 @TableLogic |
| 乐观锁字段 | 使用 @Version |
| 自动填充字段 | 使用 @TableField(fill = ...) |
| XML 多参数 | 使用 @Param |
| 事务方法 | 使用 @Transactional(rollbackFor = Exception.class) |
| 忽略插件 | @InterceptorIgnore 必须谨慎并评审 |
不要把注解当成业务逻辑替代品。参数校验可以拦截基础非法值,但业务唯一性、状态流转、数据权限仍然要在 Service 层处理。
SQL 编写规范
SQL 编写要优先保证可读性、可维护性、安全性和性能。简单单表查询优先 LambdaWrapper,复杂 SQL 使用 XML。自定义 SQL 必须明确字段、表别名、逻辑删除、租户条件和排序规则。
SQL 基本规范:
| 规则 | 要求 |
|---|---|
| 查询字段 | 禁止 SELECT * |
| 表别名 | 多表 JOIN 必须使用清晰别名 |
| 字段别名 | VO 查询字段必须显式别名 |
| 逻辑删除 | 自定义 SQL 手动加 deleted = 0 |
| 多租户 | 自定义 SQL 明确租户条件或确保插件生效 |
| 动态条件 | 使用 <if>、<foreach> |
| 参数绑定 | 使用 #{},禁止直接拼接用户输入 |
| 排序字段 | 必须白名单转换后才能 ${} |
| 分页 | 必须限制 pageSize 和深分页 |
| 大表查询 | 必须带范围条件 |
| 模糊查询 | 大表避免无索引 %keyword% |
| 批量操作 | 使用批量方法或批量 SQL |
Wrapper 使用规范:
| 场景 | 推荐 |
|---|---|
| 单表等值查询 | LambdaWrapper |
| 单表动态条件 | LambdaWrapper |
| 单表更新 | LambdaUpdateWrapper |
| 多表 JOIN | XML |
| 报表统计 | XML |
| 窗口函数 | XML |
| UNION | XML |
| 递归查询 | XML |
| 排序来自前端 | 白名单 |
SQL 安全要求:
| 风险点 | 处理方式 |
|---|---|
${} | 只允许白名单后的字段名、排序方向 |
apply | 禁止拼接前端输入 |
last | 禁止拼接前端输入 |
inSql | 谨慎使用,禁止拼接用户输入 |
| 前端排序 | 白名单 |
| 查询字段 | 白名单 |
| 动态表名 | 上下文或枚举控制 |
| 无 WHERE 更新 | 拦截或代码评审禁止 |
事务使用规范
事务必须放在 Service 层,围绕一组必须同时成功或失败的数据库操作。事务范围要小,不要把文件上传、Excel 解析、远程调用、MQ 发送、耗时计算放入事务。
事务基本规范:
| 规则 | 要求 |
|---|---|
| 注解位置 | Service public 方法 |
| 回滚规则 | rollbackFor = Exception.class |
| 事务范围 | 只包数据库一致性操作 |
| 自调用 | 禁止依赖同类内部方法触发事务 |
| 异常处理 | catch 后需要回滚则必须继续抛出 |
| 批量操作 | 控制批次大小 |
| 多数据源 | 避免本地事务跨多个数据源 |
| MQ 发送 | 事务提交后发送 |
| 缓存清理 | 事务提交后删除 |
| 外部调用 | 放在事务外或使用补偿机制 |
事务适用场景:
| 场景 | 是否需要事务 |
|---|---|
| 单表简单查询 | 不需要 |
| 单表新增 | 通常可不用,但业务统一可加 |
| 多表新增 | 需要 |
| 状态流转 | 需要 |
| 扣减库存 | 需要 |
| 批量导入入库 | 需要,建议分批 |
| 删除并清理关联表 | 需要 |
| 导出查询 | 不需要 |
| 远程调用 | 不应放事务内 |
| MQ 发送 | 事务后事件 |
事务失效高频原因:
| 原因 | 说明 |
|---|---|
| 方法不是 public | Spring AOP 不生效 |
| 同类自调用 | 未经过代理对象 |
| 异常被吞掉 | 事务认为正常结束 |
| 未配置 rollbackFor | 受检异常不回滚 |
| 异步线程 | 事务上下文不跨线程 |
| 数据库非事务引擎 | MyISAM 不支持事务 |
| 多数据源混用 | 事务管理器不一致 |
异常处理规范
异常处理要统一、清晰、可追踪。业务异常用于可预期的业务失败,系统异常用于非预期错误,参数异常由全局异常处理器统一转换。不要把异常堆栈直接返回给前端。
异常分类建议:
| 类型 | 示例 | 处理方式 |
|---|---|---|
| 参数异常 | 字段为空、格式错误 | 返回参数错误 |
| 业务异常 | 数据不存在、状态不允许 | 返回业务提示 |
| 权限异常 | 未登录、无权限 | 返回认证或授权错误 |
| 数据异常 | 唯一约束、外键冲突 | 转换为业务提示 |
| 系统异常 | NPE、连接失败 | 返回通用错误,日志记录堆栈 |
| 外部服务异常 | 第三方接口失败 | 返回明确降级提示 |
| 幂等异常 | 重复提交 | 返回已有结果或重复提示 |
业务异常使用要求:
| 规则 | 要求 |
|---|---|
| 可预期失败 | 抛 BusinessException |
| 提示文案 | 面向用户,清晰可读 |
| 日志级别 | 通常 warn |
| 系统异常 | 使用 error 记录堆栈 |
| 前端响应 | 使用统一响应结构 |
| TraceId | 异常响应建议返回 TraceId |
| 数据库异常 | 转换为业务异常或统一错误 |
| 敏感信息 | 不返回 SQL、密码、Token、堆栈 |
常见业务提示示例:
| 场景 | 推荐提示 |
|---|---|
| 数据不存在 | 商品不存在 |
| 唯一冲突 | 商品编码已存在 |
| 乐观锁失败 | 数据已被修改,请刷新后重试 |
| 无权限 | 无权限操作该数据 |
| 状态非法 | 当前状态不允许执行该操作 |
| 库存不足 | 库存不足 |
| 重复提交 | 请勿重复提交 |
日志记录规范
日志用于排查问题和审计行为,不是用于随意打印对象。关键业务操作要记录,敏感信息必须脱敏,生产环境禁止完整 SQL 和完整请求参数刷屏。
日志级别规范:
| 级别 | 使用场景 |
|---|---|
trace | 极细粒度调试,生产默认关闭 |
debug | 本地开发排查 |
info | 关键业务成功、任务完成、状态流转 |
warn | 可预期异常、业务失败、参数异常、慢操作 |
error | 系统异常、数据库异常、外部服务不可用 |
日志必须包含的上下文:
| 场景 | 建议字段 |
|---|---|
| 业务操作 | 租户ID、用户ID、业务ID、操作结果 |
| 异常日志 | TraceId、异常类型、关键参数摘要 |
| 导入导出 | 任务ID、文件ID、成功数、失败数 |
| MQ 消费 | messageId、topic、consumerGroup |
| 定时任务 | 任务名、租户ID、执行日期、数量 |
| 慢 SQL | Mapper 方法、耗时、TraceId |
| 权限拒绝 | 用户ID、接口、资源ID |
日志禁止项:
| 内容 | 规则 |
|---|---|
| 密码 | 禁止记录 |
| Token | 禁止完整记录 |
| 密钥 | 禁止记录 |
| 身份证号 | 脱敏 |
| 手机号 | 脱敏 |
| 银行卡号 | 脱敏 |
| SQL 完整参数 | 生产禁止 |
| 大请求体 | 截断或不记录 |
| 文件内容 | 禁止记录 |
推荐日志表达:
新增商品成功,租户ID:{},商品ID:{},商品编码:{}
修改订单状态成功,订单ID:{},原状态:{},新状态:{}
MQ消息消费失败,消息ID:{},消费组:{},错误:{}2
3
不推荐日志表达:
执行成功
参数:{}
报错了
用户信息:完整DTO2
3
4
测试规范
测试要覆盖核心业务规则、Mapper SQL、插件行为和异常分支。不要只测成功路径,也不要依赖生产数据做测试。复杂 SQL、分页、租户、逻辑删除、乐观锁、多数据源必须有集成测试兜底。
测试分类:
| 类型 | 测试重点 |
|---|---|
| 单元测试 | 工具类、枚举、转换器、参数清洗 |
| Mapper 测试 | SQL、ResultMap、动态条件 |
| Service 测试 | 业务规则、事务、异常分支 |
| Controller 测试 | 路径、参数校验、响应结构 |
| 插件测试 | 分页、租户、数据权限、逻辑删除、乐观锁 |
| 数据库测试 | Testcontainers 验证真实方言 |
| SQL 回归 | 复杂报表、多表 JOIN、统计结果 |
测试数据规范:
| 规则 | 要求 |
|---|---|
| 测试数据 | 使用独立 SQL 脚本 |
| 数据隔离 | 每个测试类准备自己的数据 |
| 固定 ID | 便于断言 |
| 清理数据 | 测试前清理或使用事务回滚 |
| 多租户 | 准备不同租户数据 |
| 权限 | 准备不同角色和部门数据 |
| H2 | 只用于轻量测试 |
| MySQL 方言 | 使用 Testcontainers |
必须覆盖的测试场景:
| 模块 | 场景 |
|---|---|
| 新增 | 成功、唯一性冲突、参数非法 |
| 修改 | 成功、数据不存在、乐观锁冲突 |
| 删除 | 成功、有引用不能删、无权限 |
| 分页 | 正常分页、最大页大小、深分页 |
| 逻辑删除 | 删除后不可见 |
| 多租户 | 租户间数据隔离 |
| 数据权限 | 只能访问授权部门 |
| 批量导入 | 成功、行级失败、重复数据 |
| 多数据源 | 数据源切换正确 |
| 事务 | 异常后回滚 |
性能规范
性能规范要从 SQL、索引、分页、批量操作、事务、缓存和连接池多个层面控制。不要等慢 SQL 出现后再治理,查询设计阶段就要考虑数据量和访问频率。
SQL 性能规范:
| 规则 | 要求 |
|---|---|
| 查询字段 | 禁止 SELECT * |
| 大表查询 | 必须带索引条件 |
| 模糊查询 | 避免无索引 %keyword% |
| 分页查询 | 限制 pageSize 和深分页 |
| 排序字段 | 尽量命中索引 |
| JOIN | 控制关联表数量 |
| COUNT | 复杂分页可优化 COUNT |
| N+1 查询 | 批量查询后内存组装 |
| 大字段 | 列表不查大字段 |
| 批量写入 | 分批处理 |
索引设计规范:
| 场景 | 建议 |
|---|---|
| 租户表 | 索引前缀包含 tenant_id |
| 状态列表 | (tenant_id, status, create_time) |
| 编码唯一 | (tenant_id, code) 唯一索引 |
| 部门数据权限 | (tenant_id, dept_id) |
| 时间范围查询 | 包含时间字段 |
| 排序查询 | 考虑排序字段 |
| 低区分度字段 | 不单独建索引 |
| 频繁更新字段 | 谨慎建过多索引 |
分页性能规范:
| 场景 | 建议 |
|---|---|
| 后台分页 | 限制最大页大小 |
| 深分页 | 限制 offset |
| 移动端滚动 | 游标分页 |
| 大数据导出 | 异步导出 |
| 复杂 COUNT | 自定义 COUNT 或关闭 COUNT |
| 报表分页 | 使用汇总表 |
缓存性能规范:
| 场景 | 建议 |
|---|---|
| 字典 | 缓存 |
| 参数配置 | 缓存 |
| 用户权限 | 缓存 |
| 数据权限 | 缓存 |
| 热点详情 | 可缓存 |
| 强一致数据 | 谨慎缓存 |
| 缓存更新 | 更新数据库后删缓存 |
| 缓存过期 | TTL 加随机值 |
事务性能规范:
| 场景 | 建议 |
|---|---|
| 大批量导入 | 分批事务 |
| 远程调用 | 不放事务内 |
| 文件处理 | 不放事务内 |
| 长报表查询 | 不加事务 |
| 大表更新 | 分批执行 |
| 锁冲突 | 缩短事务时间 |
安全规范
安全规范要覆盖 SQL 注入、接口越权、数据权限、租户隔离、敏感信息、文件上传、导入导出和日志脱敏。安全问题不能只依赖前端控制,后端必须做最终校验。
SQL 安全规范:
| 风险点 | 规范 |
|---|---|
| SQL 参数 | 使用 #{} |
| 排序字段 | 白名单 |
| 查询字段 | 白名单 |
apply | 禁止拼接前端输入 |
last | 禁止拼接前端输入 |
inSql | 谨慎使用 |
| 动态表名 | 后端枚举或上下文控制 |
| 无 WHERE 更新 | 拦截或禁止 |
| 自定义 SQL | 必须代码评审 |
接口安全规范:
| 场景 | 要求 |
|---|---|
| 登录接口 | 限流、防爆破 |
| 详情接口 | 校验数据权限 |
| 修改接口 | 校验数据权限和状态 |
| 删除接口 | 校验权限和关联数据 |
| 导出接口 | 权限校验、数量限制、操作日志 |
| 导入接口 | 文件类型、大小、模板校验 |
| 附件下载 | 鉴权后下载或签名 URL |
| 批量操作 | 校验所有 ID 权限 |
| 管理接口 | 角色或权限码控制 |
数据安全规范:
| 内容 | 要求 |
|---|---|
| 密码 | 单向哈希存储 |
| Token | 不入日志 |
| 手机号 | 展示和日志脱敏 |
| 身份证 | 脱敏和加密 |
| 银行卡 | 脱敏和加密 |
| 密钥 | 配置中心或 Secret 管理 |
| 个人信息导出 | 记录敏感操作日志 |
| 删除数据 | 优先逻辑删除 |
| 多租户 | 所有业务表租户隔离 |
文件安全规范:
| 场景 | 要求 |
|---|---|
| 上传类型 | 扩展名和 MIME 双校验 |
| 文件大小 | 限制 |
| 文件名 | 不直接作为存储对象名 |
| 访问路径 | 私有文件使用签名 URL |
| 下载 | 鉴权 |
| 预览 | 鉴权 |
| 删除 | 校验业务权限 |
| 病毒扫描 | 高安全场景接入 |
项目规范落地清单
规范需要通过模板、封装、评审和测试落地,不能只停留在文档中。
| 规范项 | 落地方式 |
|---|---|
| 分层职责 | 代码模板、代码评审 |
| 命名规范 | 代码生成器、包结构模板 |
| 注解规范 | Entity 模板、DTO 模板 |
| SQL 规范 | XML 模板、SQL Review |
| 事务规范 | Service 模板、测试覆盖 |
| 异常规范 | 统一异常处理器 |
| 日志规范 | 操作日志切面、脱敏工具 |
| 测试规范 | 基础测试类、测试数据脚本 |
| 性能规范 | 慢 SQL 监控、分页限制 |
| 安全规范 | 权限拦截器、数据权限插件、代码扫描 |
项目开发前建议统一以下基础设施:
| 基础能力 | 是否建议统一 |
|---|---|
ApiResult | 是 |
PageQuery / PageResult | 是 |
BusinessException | 是 |
GlobalExceptionHandler | 是 |
BaseEntity | 是 |
CurrentUserContext | 是 |
CurrentTenantContext | 是 |
MetaObjectHandler | 是 |
MybatisPlusInterceptor | 是 |
OperationLog | 是 |
DictFormat | 是 |
DataPermissionHandler | 是 |
RedisCacheService | 是 |
TraceIdFilter | 是 |
最终标准可以概括为:接口层稳定,业务层清晰,数据层克制;简单查询用 LambdaWrapper,复杂查询用 XML;所有写操作考虑事务、权限、租户、审计和并发;所有生产环境配置默认安全、低噪、可观测。