Spring Boot + MyBatis 实战工作流

从 Mapper.xml 到 Controller 的分层编码规范 — 以"订单管理 API"为例,完整走一遍真实开发流程

Spring Boot 3.x MyBatis Java 17 分层架构 RESTful API 事务管理
案例背景: 产品提了个需求 ——"做一个订单管理模块,要能增删改查、多条件搜索、分页"。本教程用一个真实的需求走完整条链路:先建表,再写 Entity/DTO/VO,然后 Mapper.xml,接着 Service 事务逻辑,最后 Controller 对外 API。每一步都有代码、有 IDE 截图、有终端输出、有踩坑记录。

DAY 1 · 09:00一、分层架构总览

Spring Boot + MyBatis 项目最经典的四层架构。每一层只做自己的事,不越界。层与层之间靠接口(Interface)解耦,这样某一层换了实现,其他层不用动。

Controller 层 Service 层 Mapper 层 Database (MySQL)
接收 HTTP 请求 业务逻辑 + 事务 SQL 映射 + 数据访问 持久化存储
Entity DTO VO — 数据传输对象,在层间流转
分层架构 = 公司的组织架构
  • Controller = 前台接待:客户(HTTP请求)进门先找前台。前台负责登记、检查证件(参数校验),不亲自干活,只负责把客户领到对应的部门。
  • Service = 业务部门:真正干活的人。订单要不要成立?库存够不够?要不要扣款?这些业务判断都在这。如果中间出错,整个业务要回滚(事务)。
  • Mapper = 档案室管理员:只负责存取档案(读写数据库),不懂业务。你给 SQL,我执行,把数据给你。
  • Entity/DTO/VO = 不同格式的申请表:前台用的登记表(DTO)、档案室的标准档案(Entity)、给老板看的汇报表(VO)——同一条信息,不同场合用不同格式。

每一层的职责边界(千万别越界)

层级 应该做的 不应该做的 核心注解/组件
Controller 接收请求、参数校验(@Valid)、调用Service、组装返回结果 写业务逻辑、直接调Mapper、处理事务 @RestController / @RequestMapping
Service 业务逻辑编排、事务管理、调用多个Mapper组合数据 直接处理HTTP参数、写SQL、返回HTTP响应 @Service / @Transactional
Mapper(DAO) 定义数据访问接口、对应XML里的SQL 写业务判断、调用其他Service、处理事务 @Mapper / XML映射文件
Entity/DTO/VO 纯数据载体,只放字段和getter/setter 放业务逻辑方法、依赖Spring容器 POJO / Lombok @Data
一个常见的新手错误:在 Controller 里直接 if/else 写业务逻辑,甚至直接 if(status == 1) mapper.update()。这会让 Controller 变成"上帝类",没法测试,没法复用。记住:Controller 越薄越好,业务全往 Service 塞。

项目目录结构(IDEA 里的样子)

order-service - [~/projects/order-service] - src/main/java/com/example/order
📁 order
📁 controller
📁 service
📁 mapper
📁 entity
📁 dto
📁 vo
📁 config
📁 exception
📁 common
📁 resources
📁 mapper (XML)
📄 application.yml
1// com.example.order 包结构
2package com.example.order;
3
4// 每一层一个包,职责清晰
5controller/ // OrderController.java
6service/ // OrderService + OrderServiceImpl
7mapper/ // OrderMapper.java (接口)
8entity/ // Order.java (对应DB表)
9dto/ // OrderCreateDTO, OrderQueryDTO
10vo/ // OrderVO, OrderListVO
11exception/ // GlobalExceptionHandler, BusinessException
12common/ // Result, PageResult 通用响应
UTF-8LFJava 17Order Application

DAY 1 · 10:15二、Entity → DTO → VO 对象转换

这是初学者最容易糊在一起的三个概念。先把它们区分清楚,后面写代码会轻松很多。

前端请求 DTO Entity DB
↑ 请求进来:DTO 转 Entity 存库
DB Entity VO 前端响应
↓ 响应回去:Entity 转 VO 返回
对象 角色定位 对应方向 包含内容
Entity 数据库表的 Java 映射 Service ↔ Mapper 表的所有字段,带主键、创建时间等
DTO 接收前端入参 Controller → Service 只含创建/查询需要的字段,带校验注解
VO 返回给前端的视图对象 Controller → 前端 前端需要展示的字段,可能来自多张表
为什么不用同一个类?因为需求会变!
今天订单表有 5 个字段,你用一个 Order 类搞定一切。明天产品说"创建订单时不需要填创建时间(后端自动生成)",后天说"列表页要显示用户昵称(要 join 用户表)"。如果用同一个类,要么前端能传不该传的字段(安全隐患),要么返回一堆前端用不到的字段(浪费带宽)。分开后,改一处不影响另一处。

① Entity — 直接对应数据库表

Order.java - com.example.order.entity
📁 entity
📄 Order.java
📄 OrderItem.java
📁 dto
📁 vo
1package com.example.order.entity;
2
3import lombok.Data;
4import java.time.LocalDateTime;
5
6@Data
7public class Order {
8 // 主键,对应表里的 id 字段
9 private Long id;
10
11 // 订单号,唯一
12 private String orderNo;
13
14 // 用户ID
15 private Long userId;
16
17 // 订单金额(分)
18 private Long amount;
19
20 // 状态:0待支付 1已支付 2已取消
21 private Integer status;
22
23 // 创建时间(数据库自动生成,前端不该传)
24 private LocalDateTime createTime;
25
26 private LocalDateTime updateTime;
27}
Order.java7 fieldsJava 17

② DTO — 接收前端入参,带校验

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 dto
📄 OrderCreateDTO.java
📄 OrderUpdateDTO.java
@Data public class OrderCreateDTO { // 订单号由后端生成,不接收前端传入 @NotNull(message = "用户ID不能为空") private Long userId; @NotNull(message = "金额不能为空") @Min(value = 1, message = "金额必须大于0") private Long amount; @NotBlank(message = "收货地址不能为空") private String address; // 注意:没有 id、orderNo、status、createTime 字段 // 这些都是后端自己生成的,不该让前端传 }
专业定义 DTO + Bean Validation = 入参对象的声明式校验。@NotBlank/@NotNull/@Size 等注解在 DTO 字段上一键声明规则,Controller 方法参数加 @Valid 触发校验。一次定义,处处复用,校验逻辑从业务代码中完全抽离。
安全要点: DTO 故意不包含 idstatuscreateTime。如果用 Entity 直接接收前端请求,用户可以在请求体里塞 "status": 1 把订单直接变成已支付 —— 这就是"对象注入攻击"。用 DTO 物理隔离,从根本上杜绝。

③ VO — 返回给前端的视图对象

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📄 OrderService.java
📄 UserServiceImpl.java
@Data public class OrderVO { private Long id; private String orderNo; private Long amount; // 状态文案:前端不用判断 0/1/2,直接显示 private String statusText; // 来自用户表(join 查出来的) private String userName; private String userPhone; // 格式化后的时间字符串 private String createTimeText; // 注意:不返回 userId、updateTime 等前端不需要的字段 }
专业定义 接口+实现分离 = 面向接口编程的标准实践。Spring 注入时用接口类型,实现调用方与实现解耦。这是依赖倒置原则(DIP)的体现。

④ 对象转换(手动 or MapStruct)

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 vo
📄 OrderVO.java
📄 UserVO.java
📄 ProductVO.java
// 手动转换:简单直观,字段少时推荐 public class OrderConverter { public static Order toEntity(OrderCreateDTO dto) { if (dto == null) return null; Order order = new Order(); order.setUserId(dto.getUserId()); order.setAmount(dto.getAmount()); // 订单号、状态、时间由 Service 设置 return order; } public static OrderVO toVO(Order entity) { if (entity == null) return null; OrderVO vo = new OrderVO(); vo.setId(entity.getId()); vo.setOrderNo(entity.getOrderNo()); vo.setAmount(entity.getAmount()); vo.setStatusText(statusToText(entity.getStatus())); vo.setCreateTimeText( entity.getCreateTime().format( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") ) ); return vo; } private static String statusToText(Integer status) { return switch (status) { case 0 -> "待支付"; case 1 -> "已支付"; case 2 -> "已取消"; default -> "未知"; }; } }
专业定义 VO (View Object) = 返回给前端的视图对象。与 DTO 的区别:DTO 是入参,VO 是出参。VO 可以裁剪敏感字段(如密码、内部 ID)、聚合多个实体数据、格式化日期/金额。永远不要把 Entity 直接返回给前端——这是 P0 级安全规范。
MapStruct 提示: 字段超过 10 个时,手写转换又长又容易漏。这时用 @Mapper(MapStruct 的,不是 MyBatis 的)自动生成转换代码:接口里定义好,编译期自动生成实现类,性能和手写一样。

DAY 1 · 11:30三、Mapper层 — MyBatis XML 实战

Mapper 层是 MyBatis 的核心。接口定义方法签名,XML 写 SQL。两者通过 namespace + 方法名关联。

① Mapper 接口 — 先定义方法

OrderMapper.java - com.example.order.mapper
📁 mapper
📄 OrderMapper.java
📁 resources
📁 mapper
📄 OrderMapper.xml
1package com.example.order.mapper;
2
3import com.example.order.entity.Order;
4import org.apache.ibatis.annotations.Mapper;
5import org.apache.ibatis.annotations.Param;
6import java.util.List;
7
8@Mapper
9public interface OrderMapper {
10
11 // 基本CRUD
12 int insert(Order order);
13 int deleteById(@Param("id") Long id);
14 int updateById(Order order);
15 Order selectById(@Param("id") Long id);
16
17 // 多条件查询(动态SQL)
18 List<Order> selectByCondition(OrderQueryDTO query);
19
20 // 批量插入
21 int batchInsert(@Param("list") List<Order> list);
22}
OrderMapper.java@Mapper registeredMyBatis

② 基本 CRUD — MyBatis XML

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📂 impl
📄 OrderServiceImpl.java
📄 UserServiceImpl.java
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.order.mapper.OrderMapper"> <!-- 插入:useGeneratedKeys 回填自增主键 --> <insert id="insert" parameterType="com.example.order.entity.Order" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_order (order_no, user_id, amount, status, create_time) VALUES (#{orderNo}, #{userId}, #{amount}, #{status}, #{createTime}) </insert> <delete id="deleteById"> DELETE FROM t_order WHERE id = #{id} </delete> <update id="updateById" parameterType="com.example.order.entity.Order"> UPDATE t_order SET order_no = #{orderNo}, user_id = #{userId}, amount = #{amount}, status = #{status} WHERE id = #{id} </update> <select id="selectById" resultType="com.example.order.entity.Order"> SELECT id, order_no, user_id, amount, status, create_time, update_time FROM t_order WHERE id = #{id} </select> </mapper>
面试金句 @Transactional 最佳实践 = (1) 加在 public 方法上 (2) rollbackFor = Exception.class (3) 不要在事务内做远程调用 (4) 只读查询加 readOnly = true

③ 动态 SQL — 多条件查询(重点)

订单列表搜索:状态可选、时间范围可选、关键词可选。用 <where> + <if> 拼动态条件。

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 converter
📄 OrderConverter.java
📄 UserConverter.java
<select id="selectByCondition" resultType="com.example.order.entity.Order"> SELECT id, order_no, user_id, amount, status, create_time FROM t_order <where> <!-- status 不为 null 才加条件 --> <if test="status != null"> AND status = #{status} </if> <!-- 开始时间 --> <if test="startTime != null"> AND create_time >= #{startTime} </if> <!-- 结束时间 --> <if test="endTime != null"> AND create_time <= #{endTime} </if> <!-- 关键词模糊搜索:注意用 CONCAT,不要用 ${} --> <if test="keyword != null and keyword != ''"> AND order_no LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY create_time DESC </select>
专业定义 对象转换 = Entity ↔ DTO ↔ VO 之间的映射。手写转换适合字段少的场景;字段多时用 MapStruct(编译期生成代码,性能等同手写)而非 BeanUtils(运行时反射,性能差 10-50 倍)。面试亮点:说明 MapStruct 的 @Mapper 接口 + 编译期 Lombok 生成原理。
<where> 标签的妙用:它会自动去掉第一个多余的 ANDOR。如果所有 if 都不成立,它连 WHERE 这个词都不输出 —— 避免 SELECT ... FROM t_order WHERE 这种语法错误。
LIKE 模糊查询规范: 正确LIKE CONCAT('%', #{keyword}, '%') —— 参数化,防注入
错误LIKE '%${keyword}%' —— 字符串拼接,SQL 注入漏洞!

④ <choose> — 类似 switch-case

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📂 impl
📄 OrderServiceImpl.java
📄 UserServiceImpl.java
<!-- 需求:管理员能查所有订单,普通用户只能查自己的 --> <select id="selectByRole" resultType="Order"> SELECT * FROM t_order <choose> <when test="role == 'admin'"> WHERE 1=1 </when> <when test="role == 'vip'"> WHERE user_id = #{userId} OR amount > 10000 </when> <otherwise> WHERE user_id = #{userId} </otherwise> </choose> ORDER BY create_time DESC </select>
面试金句 @Transactional 自调用失效 = 同类内 A 调 B(加了 @Transactional),B 的事务不生效。原因:Spring AOP 基于代理,自调用绕过代理。修复:注入自己/AopContext/拆类。面试 P0 考点。

⑤ 批量操作 — <foreach>

📁 order-service
📂 src
📂 main
📂 resources
📂 mapper
📄 OrderMapper.xml
📄 UserMapper.xml
<!-- 批量插入:一条SQL搞定,比循环insert快50倍 --> <insert id="batchInsert" parameterType="java.util.List"> INSERT INTO t_order (order_no, user_id, amount, status, create_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.orderNo}, #{item.userId}, #{item.amount}, #{item.status}, #{item.createTime}) </foreach> </insert> <!-- 生成结果:INSERT INTO ... VALUES (...), (...), (...) --> <!-- 批量插入 + 存在则更新(ON DUPLICATE KEY UPDATE) --> <insert id="batchInsertOrUpdate"> INSERT INTO t_order (order_no, user_id, amount, status) VALUES <foreach collection="list" item="item" separator=","> (#{item.orderNo}, #{item.userId}, #{item.amount}, #{item.status}) </foreach> ON DUPLICATE KEY UPDATE amount = VALUES(amount), status = VALUES(status) </insert>
专业定义 MyBatis XML 映射 = SQL 与 Java 解耦的经典方案。namespace 绑定 Mapper 接口,id 对应方法名,resultType/resultMap 处理 ORM 映射。面试考点:#{}(预编译参数)vs ${}(字符串拼接)的区别——前者防注入,后者有注入风险。
# 批量插入 vs 循环插入,性能对比(1000条数据) $ java -jar order-service.jar --mode=loop 循环插入 1000 条... 耗时: 15.2s | 执行SQL次数: 1000 | 平均每条: 15ms $ java -jar order-service.jar --mode=batch 批量插入 1000 条(每批500)... 耗时: 0.3s | 执行SQL次数: 2 | 提速 50倍 # 为什么?循环插入每次都要建立网络往返 + 解析SQL,批量插入一次网络传完

⑥ #{} vs ${} — 一个能要命的区别

写法 机制 示例 安全性
#{param} 预编译占位符 ?,参数后填入 WHERE id = ?,参数 1 安全,防注入
${param} 字符串直接拼接进SQL WHERE id = + 用户输入 危险!SQL注入
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 common
📄 Result.java
📄 PageResult.java
// 假设前端传入 keyword = "'; DROP TABLE t_order; --" // 危险写法:${} 直接拼接 // 生成的SQL:SELECT * FROM t_order WHERE order_no LIKE '%'; DROP TABLE t_order; --%' // 后果:表没了! SELECT * FROM t_order WHERE order_no LIKE '%${keyword}%' // 安全写法:#{} 预编译 // 生成的SQL:SELECT * FROM t_order WHERE order_no LIKE ? // 参数:'%'; DROP TABLE t_order; --%' (被当成普通字符串,不会执行) SELECT * FROM t_order WHERE order_no LIKE CONCAT('%', #{keyword}, '%') // ${} 唯一合法场景:动态表名、列名(不能预编译的地方) SELECT * FROM ${tableName} WHERE id = #{id} // 但这种情况要自己在Service层做白名单校验!
专业定义 Result<T> 统一响应 = 所有 API 返回 {code, message, data}。泛型 T 让 data 类型安全。配合全局异常处理器,Controller 里不需要 try-catch
DataGrip - t_order - 查询结果
SELECT id, order_no, user_id, amount, status, create_time FROM t_order WHERE status = 1 ORDER BY create_time DESC LIMIT 10;
id
order_no
user_id
amount
status
create_time
1001
ORD20260613001
8888
19900
1 (已支付)
2026-06-13 10:23:45
1002
ORD20160613002
9999
5800
1 (已支付)
2026-06-13 09:15:22
1003
ORD20160613003
7777
12800
1 (已支付)
2026-06-13 08:50:11
3 rows in set (0.00 sec) · 10 条记录已返回
面试高频题:为什么 #{} 能防注入?因为 MyBatis 会先用 ? 占位生成预编译SQL,发送给数据库编译。编译完成后,参数才以"纯数据"形式填入 —— 数据库已经知道 ? 位置的内容是值不是指令,不会解析成SQL。这就是 JDBC PreparedStatement 的原理。

⑦ 数据库表结构 — DDL 参考

DataGrip - t_order 表结构
DESC t_order;
Field
Type
Null
Key
Default
Extra
id
bigint
NO
PRI
auto_increment
主键
order_no
varchar(32)
NO
UNI
-
订单号唯一索引
user_id
bigint
NO
MUL
-
普通索引 idx_user_id
amount
bigint
NO
-
0
金额(分)
status
tinyint
NO
-
0
0待支付/1已支付/2已取消
create_time
datetime
NO
-
CURRENT_TIMESTAMP
创建时间
引擎: InnoDB · 字符集: utf8mb4 · 行格式: DYNAMIC

DAY 1 · 14:00四、Service层 — 业务逻辑与事务

Service 是真正干活的一层。规范的做法:先定义接口,再写实现类。这样便于 Mock 测试、切换实现。

① 接口 + 实现 — 标准写法

📁 order-service
📂 src
📂 main
📂 resources
📂 mapper
📄 OrderMapper.xml
📄 UserMapper.xml
// ============ 接口 ============ public interface OrderService { OrderVO createOrder(OrderCreateDTO dto); OrderVO getOrderById(Long id); PageResult<OrderVO> listOrders(OrderQueryDTO query); void cancelOrder(Long id); } // ============ 实现类 ============ @Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired UserService userService; // 调用其他Service @Override @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { // 1. 业务校验 User user = userService.getById(dto.getUserId()); if (user == null) { throw new BusinessException("用户不存在"); } // 2. DTO 转 Entity,填充后端字段 Order order = OrderConverter.toEntity(dto); order.setOrderNo(generateOrderNo()); // 生成订单号 order.setStatus(0); // 待支付 order.setCreateTime(LocalDateTime.now()); // 3. 入库 orderMapper.insert(order); // 4. 返回 VO return OrderConverter.toVO(order); } private String generateOrderNo() { return "ORD" + System.currentTimeMillis(); } }
专业定义 MyBatis 动态 SQL = 通过 <if>/<choose>/<where>/<foreach> 标签在运行时拼装 SQL。<if test="name != null"> 条件成立时拼接 SQL 片段。OGNL 表达式做条件判断。<where> 自动处理首个 AND/OR,避免语法错误。

② @Transactional 使用规范

写法 评价 原因
@Transactional(rollbackFor = Exception.class) ✅ 正确 所有异常都回滚,推荐默认写法
@Transactional(无参数) ⚠️ 风险 默认只回滚 RuntimeException,抛 IOException 不回滚
放在 private 方法上 ❌ 无效 Spring AOP 基于代理,代理不了 private 方法
放在 Controller 上 ❌ 错误 职责混乱,事务应该在 Service 层
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 exception
📄 GlobalExceptionHandler.java
📄 BusinessException.java
// ✅ 正确写法 @Service public class OrderServiceImpl { @Transactional(rollbackFor = Exception.class) public void payOrder(Long orderId) { Order order = orderMapper.selectById(orderId); order.setStatus(1); orderMapper.updateById(order); // 如果这里抛异常,上面的 update 自动回滚 inventoryService.deduct(orderId); } } // ❌ 错误写法:没加 rollbackFor,抛 IOException 不回滚 @Transactional public void uploadAndSave(MultipartFile file) throws IOException { // 文件上传成功后,数据库保存时抛 IOException // 此时事务不会回滚!数据不一致 }
专业定义 @RestControllerAdvice = AOP 拦截所有 Controller 异常,通过 @ExceptionHandler 精确匹配异常类型,统一转换为标准响应。

③ 事务失效的 7 个场景(面试必问)

# 场景 原因 修复方法
1 方法不是 public Spring 基于代理实现事务,JDK 动态代理只能代理 public 方法 把方法改成 public
2 自调用(同类内部调用) this.method() 不走代理,事务不生效 注入自己(AopContext) 或拆到另一个 Service
3 异常被 catch 吃掉 异常没抛出,Spring 感知不到,无法回滚 catch 后 re-throw,或手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()
4 抛 checked 异常但没配 rollbackFor 默认只回滚 RuntimeException,IOException 不回滚 加 rollbackFor = Exception.class
5 数据库引擎不支持事务 MyISAM 不支持事务,InnoDB 才支持 改表引擎:ALTER TABLE t_order ENGINE=InnoDB
6 传播行为配置错误 NOT_SUPPORTED 以非事务方式运行;NEVER 如果有事务则抛异常 默认 REQUIRED 即可,别乱改
7 Bean 未被 Spring 管理 类没加 @Service,或 new 出来的对象,都不归 Spring 管 加 @Service 注解,用 @Autowired 注入

④ 自调用失效场景 — 图解

OrderServiceImpl.java - 事务失效案例
📁 service
📄 OrderServiceImpl.java
1@Service
2public class OrderServiceImpl implements OrderService {
3
4 // 外部调用 methodA() —— 走代理,事务生效
5 @Transactional(rollbackFor = Exception.class)
6 public void methodA() {
7 orderMapper.insert(order1);
8 // ⚠️ this调用,不走代理!methodB 的事务失效
9 this.methodB(); // ← 问题在这
10 }
11
12 @Transactional(rollbackFor = Exception.class)
13 public void methodB() {
14 orderMapper.insert(order2);
15 throw new RuntimeException("出错"); // 不会回滚!
16 }
17}
⚠️ Warning: @Transactional on methodB ineffective (self-invocation)
📁 order-service
📂 src
📂 main
📂 resources
📂 mapper
📄 OrderMapper.xml
📄 ProductMapper.xml
// 修复方法1:注入自己(推荐) @Service public class OrderServiceImpl implements OrderService { @Autowired @Lazy // 避免循环依赖 private OrderService self; // 注入代理对象 public void methodA() { orderMapper.insert(order1); self.methodB(); // ✅ 走代理,事务生效 } } // 修复方法2:拆到另一个 Service // OrderHelperService.methodB(),在 OrderService 里注入并调用
专业定义 <choose> 标签 = MyBatis 版的 switch-case。<when test="..."/> 类似 case,<otherwise> 类似 default。只匹配第一个满足条件的分支就跳出。适合需要互斥条件判断的 SQL 拼装场景。
事务失效排查口诀:一查注解在不在(public + @Transactional),二查异常抛没抛(别 catch 吞掉),三查是不是自调用(this.method 不走代理),四查回滚配没配(rollbackFor = Exception.class)。
事务传播行为速记: REQUIRED(默认,有就加入,没有就新建)、REQUIRES_NEW(总是新建,挂起当前事务)、NESTED(嵌套事务,基于 savepoint)。99% 场景用默认的 REQUIRED 就够了。

DAY 1 · 15:30五、Controller层 — RESTful API 规范

Controller 做三件事:接请求、校验参数、调 Service。不写业务逻辑,不直接操作数据库。

① RESTful API 设计规范

方法 路径 语义 参数来源
GET /api/orders 列表查询(分页) Query String (?page=1&size=10)
GET /api/orders/{id} 查询单个详情 路径参数 (@PathVariable)
POST /api/orders 创建新订单 请求体 (@RequestBody)
PUT /api/orders/{id} 更新订单 路径参数 + 请求体
DELETE /api/orders/{id} 删除订单 路径参数

② Controller 完整写法

OrderController.java - com.example.order.controller
📁 controller
📄 OrderController.java
📁 service
📁 dto
📁 vo
📁 common
1@RestController
2@RequestMapping("/api/orders")
3public class OrderController {
4
5 @Autowired
6 private OrderService orderService;
7
8 // 列表查询(分页)
9 @GetMapping
10 public Result<PageResult<OrderVO>> list(OrderQueryDTO query) {
11 return Result.success(orderService.listOrders(query));
12 }
13
14 // 详情查询
15 @GetMapping("/{id}")
16 public Result<OrderVO> getById(@PathVariable Long id) {
17 return Result.success(orderService.getOrderById(id));
18 }
19
20 // 创建(@Valid 触发参数校验)
21 @PostMapping
22 public Result<OrderVO> create(@Valid @RequestBody OrderCreateDTO dto) {
23 return Result.success(orderService.createOrder(dto));
24 }
25}
OrderController.java4 endpointsSpring MVC

③ 统一响应 Result<T>

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 exception
📄 BusinessException.java
📄 SystemException.java
@Data public class Result<T> { private int code; // 200成功,其他失败 private String message; // 提示信息 private T data; // 业务数据 public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> error(int code, String msg) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(msg); return r; } }
专业定义 自定义业务异常 = 继承 RuntimeException(非受检),携带 errorCode。不用 Checked Exception:避免 try-catch 污染 + 配合 @Transactional 回滚 + 全局处理器统一捕获。

④ API 测试 — 创建订单

POST http://localhost:8080/api/orders Send
Headers · Content-Type: application/json
Body (raw JSON):
{ "userId": 8888, "amount": 19900, "address": "北京市朝阳区XX路1号" }
200 OK · 45ms · application/json
{ "code": 200, "message": "success", "data": { "id": 1004, "orderNo": "ORD1718260234567", "amount": 19900, "statusText": "待支付", "userName": "张三", "userPhone": "138****8888", "createTimeText": "2026-06-13 16:24" } }

⑤ API 测试 — 参数校验失败

POST http://localhost:8080/api/orders Send
Body(故意漏传 amount 和 address):
{ "userId": 8888 }
400 Bad Request · 12ms
{ "code": 400, "message": "金额不能为空; 收货地址不能为空", "data": null }

⑥ API 测试 — 更新订单(PUT)

PUT http://localhost:8080/api/orders/1001 Send
Headers · Content-Type: application/json
Body (raw JSON):
{ "amount": 25800, "address": "上海市浦东新区XX路88号" }
200 OK · 38ms
{ "code": 200, "message": "success", "data": { "id": 1001, "orderNo": "ORD20160613001", "amount": 25800, "statusText": "待支付", "createTimeText": "2026-06-13 10:23" } }

⑦ API 测试 — 列表分页查询

GET http://localhost:8080/api/orders?status=1&page=1&size=10 Send
200 OK · 58ms
{ "code": 200, "message": "success", "data": { "total": 42, "pageNum": 1, "pageSize": 10, "pages": 5, "list": [ { "id": 1003, "orderNo": "ORD...", "amount": 12800, "statusText": "已支付" }, { "id": 1002, "orderNo": "ORD...", "amount": 5800, "statusText": "已支付" } ] } }
@Valid 的工作原理: Controller 方法参数加 @Valid,Spring 会用 Hibernate Validator 校验 DTO 上的注解(@NotNull@Min 等)。校验失败抛 MethodArgumentNotValidException,被全局异常处理器捕获后转成友好提示。

DAY 1 · 16:45六、统一异常处理与响应包装

没有全局异常处理时,系统报错会返回一堆 stack trace,前端没法解析,用户看了一头雾水。加了 @RestControllerAdvice 后,所有异常都被拦截,统一返回 Result 格式。

① 没有全局处理器 vs 有全局处理器

❌ 没有全局处理器(原始报错) ✅ 有全局处理器(统一格式)
HTTP 500 Internal Server Error

{
  "timestamp": "2026-06-13T16:30:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": null,
  "trace": "java.lang.NullPointerException
    at com.example.order.service.OrderServiceImpl
    .createOrder(OrderServiceImpl.java:45)
    at java.base/jdk.internal.reflect...",
  "path": "/api/orders"
}
HTTP 500 Internal Server Error

{
  "code": 500,
  "message": "系统繁忙,请稍后重试",
  "data": null
}
注意:生产环境绝对不能把 stack trace 返回给前端。一是用户看不懂,二是会暴露技术栈和代码结构,给攻击者可乘之机。stack trace 只记录到日志文件里,前端只看到友好提示。

② GlobalExceptionHandler 完整实现

📁 order-service
📂 src
📂 main
📂 resources
📂 mapper
📄 OrderMapper.xml
📄 ProductMapper.xml
@RestControllerAdvice // 拦截所有Controller的异常 @Slf4j public class GlobalExceptionHandler { // 1. 业务异常(自定义)—— 最常见 @ExceptionHandler(BusinessException.class) public Result<Void> handleBusiness(BusinessException e) { log.warn("业务异常: {}", e.getMessage()); return Result.error(e.getCode(), e.getMessage()); } // 2. 参数校验失败 —— @Valid 触发 @ExceptionHandler(MethodArgumentNotValidException.class) public Result<Void> handleValidation(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(err -> err.getField() + ": " + err.getDefaultMessage()) .collect(Collectors.joining("; ")); return Result.error(400, msg); } // 3. 空指针 —— 兜底 @ExceptionHandler(NullPointerException.class) public Result<Void> handleNPE(NullPointerException e) { log.error("空指针异常", e); // 完整栈写日志 return Result.error(500, "系统异常"); // 前端看不到栈 } // 4. 其他所有异常 —— 最后防线 @ExceptionHandler(Exception.class) public Result<Void> handleAll(Exception e) { log.error("未知异常", e); return Result.error(500, "系统繁忙,请稍后重试"); } }
面试金句 <foreach> 批量操作 = 一条 SQL 完成批量 INSERT/IN 查询。collection 指定集合,item 元素别名,separator 分隔符。比循环 N 次 INSERT 快 50 倍——减少网络往返和 SQL 解析次数。面试考点:MySQL 默认 max_allowed_packet 限制批量大小。

③ 自定义业务异常

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📂 impl
📄 OrderServiceImpl.java
📄 UserServiceImpl.java
@Getter public class BusinessException extends RuntimeException { private final int code; public BusinessException(String message) { super(message); this.code = 400; // 默认业务错误码 } public BusinessException(int code, String message) { super(message); this.code = code; } } // 使用:在 Service 里直接抛 if (user == null) { throw new BusinessException("用户不存在"); }
专业定义 PageHelper 分页 = MyBatis 物理分页插件。原理:startPage() → ThreadLocal 分页参数 → 拦截 SQL 拼接 LIMIT → 清理 ThreadLocal。巨坑:startPage 和查询之间不能有其他 SQL。
异常处理层次: 精确优先:BusinessException 比 Exception 更精确,优先匹配。所以把具体的异常放前面,通用的 Exception.class 放最后兜底。
全局异常处理 = 统一的错误响应转换层
前端发请求遇到异常,不应该让 Service 层的技术异常(如 NullPointerException、SQLException)直接抛给前端。所有异常统一被 GlobalExceptionHandler(@ControllerAdvice + @ExceptionHandler)捕获,转换成前端能理解的标准响应格式(code + message),而不是把后端的 stack trace 直接暴露给调用方。

DAY 1 · 17:30七、分页规范与批量操作最佳实践

① PageHelper 分页规范用法

📁 order-service
📂 src
📂 main
📂 resources
📂 mapper
📄 OrderMapper.xml
📄 UserMapper.xml
@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; public PageResult<OrderVO> listOrders(OrderQueryDTO query) { // ✅ 关键:startPage 必须紧挨着查询,中间不能有任何SQL PageHelper.startPage(query.getPageNum(), query.getPageSize()); // 紧接着执行查询 —— PageHelper 会自动拼接 LIMIT List<Order> list = orderMapper.selectByCondition(query); // 用 PageInfo 包装,拿到总数、总页数等 PageInfo<Order> pageInfo = new PageInfo<>(list); // Entity 转 VO List<OrderVO> voList = list.stream() .map(OrderConverter::toVO) .collect(Collectors.toList()); return new PageResult<>( pageInfo.getTotal(), // 总记录数 pageInfo.getPageNum(), // 当前页 pageInfo.getPageSize(), // 每页大小 pageInfo.getPages(), // 总页数 voList // 当前页数据 ); } }
面试金句 #{} vs ${} = MyBatis 最经典面试题。#{} → PreparedStatement 参数占位符(?),预编译防注入${} → 纯字符串拼接,有 SQL 注入风险。唯一使用 ${} 的合法场景:动态表名/列名(分表路由),且必须白名单校验。
PageHelper 原理:基于 MyBatis 拦截器。startPage() 把分页参数存到 ThreadLocal,下一次查询执行时拦截器取出参数,自动在 SQL 末尾拼 LIMIT,并额外执行一次 SELECT COUNT(*) 查总数。所以 startPage 和查询必须紧挨着,中间隔了别的 SQL 就会分页错乱。

② PageHelper 的常见坑

错误写法 问题 正确做法
startPage 后先查了一次别的表 分页参数被别的SQL消费,目标查询没分页 startPage 紧挨目标查询
startPage 后查询返回 List 再处理 没问题,但要记得包成 PageInfo new PageInfo(list) 拿总数
多线程下用 PageHelper ThreadLocal 串号,分页混乱 用 PageHelper.startPage 的有参版本,手动清理
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📂 impl
📄 OrderServiceImpl.java
📄 UserServiceImpl.java
// ❌ 错误:startPage 和查询之间隔了别的SQL PageHelper.startPage(1, 10); User user = userMapper.selectById(8888); // ← 这条被分页了! List<Order> list = orderMapper.selectByCondition(query); // ← 这条没分页 // ✅ 正确:startPage 紧挨查询 PageHelper.startPage(1, 10); List<Order> list = orderMapper.selectByCondition(query); // ← 这条被分页
面试金句 PageHelper 三大坑 = (1) startPage 和查询间隔了别的 SQL (2) 分页后 COUNT 也慢 (3) ThreadLocal 线程安全需同线程内完成。

③ 批量操作最佳实践

DataGrip - 批量插入性能测试
-- 测试场景:插入 10000 条订单数据 -- 方式1:循环单条插入(不推荐) -- 执行了 10000 次 INSERT,每次都要网络往返 INSERT INTO t_order (order_no, user_id, amount) VALUES ('ORD001', 1, 100); INSERT INTO t_order (order_no, user_id, amount) VALUES ('ORD002', 1, 200); -- ... 重复 10000 次
方式
记录数
SQL次数
耗时
评价
循环单条插入
10000
10000
152.3s
❌ 慢
批量插入(全量)
10000
1
2.1s
⚠️ 包太大
分批插入(每批500)
10000
20
0.8s
✅ 推荐
分批插入比分页500快 —— 因为单批SQL更小,MySQL 解析+锁占用更优
# Service 层分批插入实现 $ mvn test -Dtest=BatchInsertTest Running com.example.order.BatchInsertTest... 总数据量: 10000 条 分批大小: 500 条/批 批次: 20 批 总耗时: 0.832s 平均每批: 41ms # 结论:批量大小 500-1000 是甜区,太大 MySQL 会报 packet too large

④ 分批插入 Service 实现

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📄 OrderService.java
📄 UserServiceImpl.java
@Service public class OrderBatchService { @Autowired private OrderMapper orderMapper; private static final int BATCH_SIZE = 500; @Transactional(rollbackFor = Exception.class) public void batchCreate(List<Order> allOrders) { // 用 Guava Lists.partition 分批 List<List<Order>> batches = Lists.partition(allOrders, BATCH_SIZE); for (List<Order> batch : batches) { orderMapper.batchInsert(batch); } } }
专业定义 接口+实现分离 = 面向接口编程的标准实践。Service 接口定义契约,ServiceImpl 实现逻辑。Spring 注入时用接口类型(@Autowired OrderService 而非 OrderServiceImpl),实现调用方与实现解耦。面试加分:这是依赖倒置原则(DIP)的体现。
批量大小的选择: 推荐 500-1000 条/批。太小失去批量优势,太大有两个风险:① MySQL max_allowed_packet 限制(默认 4MB),SQL 太大会报错;② 单次事务持有锁太久,影响并发。500 条是经验上的甜区。

完整工作流回顾 — 一个需求从 0 到上线

建表 Entity DTO/VO Mapper.xml
Mapper接口 Service Controller 异常处理
commit a3f2b1c "feat: 订单管理模块完整实现" Author: developer <dev@example.com> Date: 2026-06-13 src/main/java/com/example/order/ + entity/Order.java (数据库实体) + dto/OrderCreateDTO.java (创建请求DTO) + dto/OrderQueryDTO.java (查询请求DTO) + vo/OrderVO.java (返回视图对象) + mapper/OrderMapper.java (Mapper接口) + service/OrderService.java (Service接口) + service/OrderServiceImpl.java (Service实现+事务) + controller/OrderController.java (RESTful API) + common/Result.java (统一响应) + exception/BusinessException.java (业务异常) + exception/GlobalExceptionHandler.java (全局异常处理) src/main/resources/mapper/ + OrderMapper.xml (CRUD+动态SQL+批量) src/test/java/com/example/order/ + OrderServiceTest.java (单元测试) + OrderControllerTest.java (接口测试) 12 files changed, 856 insertions(+)

编码规范速查表(打印贴墙)

规范项做法
分层职责Controller 薄、Service 厚、Mapper 纯
对象分离Entity/DTO/VO 各司其职,不混用
事务注解@Transactional(rollbackFor = Exception.class)
SQL注入永远用 #{},不用 ${}
模糊查询LIKE CONCAT('%', #{kw}, '%')
分页PageHelper.startPage 紧挨查询
批量分批 500-1000 条,别循环insert
异常@RestControllerAdvice 统一处理
响应统一 Result<T> 格式
API风格RESTful:GET查/POST增/PUT改/DELETE删
分层编码 = 盖楼
地基(数据库)→ 承重墙(Entity)→ 水电管线(Mapper)→ 房间布局(Service)→ 大堂接待(Controller)。每一层都依赖下面的,但每一层都有自己的活。你不能让大堂接待员去修水管,也不能让水管工去指挥客人。盖楼要按顺序,编码也是 —— 先底层数据,再业务逻辑,最后对外接口。