05|(前端转后全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门

本篇是"前端工程师转后端"系列第 5 篇。第 4 篇我们读懂了 MySQL 表结构,知道 mall_productmall_categorymall_product_skumall_order 等表分别保存什么。现在进入 Java 后端访问数据库的第一层:MyBatis-Plus。你可以先把它理解成"让 Java 对象和数据库表互相映射,并提供常用 CRUD 能力的工具"。本篇不要求你提前懂 MyBatis 或 ORM,会用前端熟悉的 API client、TypeScript interface、数组 filter / map 来类比。
说明:本文仍然基于当前 fullstack-mall 后端工程。前端代码均为"前端侧示意代码",用于帮助理解概念,不代表仓库存在真实前端源码。

1. 这篇解决什么问题

前面我们已经能从接口追到 Controller,也能读懂数据库表。但中间还有一段你必须掌握的链路:Java 代码如何查询 MySQL?

如果不用任何框架,后端访问数据库大概会写很多重复代码:创建连接、写 SQL、设置参数、执行查询、遍历结果集、把每一列手动塞到 Java 对象里、处理异常、关闭连接。真实项目当然不能每个接口都重复写这些样板。

当前项目使用 MyBatis-Plus,它出现在 backend/service/pom.xml 的依赖中,也有专门配置:

text 复制代码
backend/service/src/main/java/com/example/fullstackmall/service/config/MybatisPlusConfig.java

商品相关数据库访问主要在这些文件:

text 复制代码
backend/service/src/main/java/com/example/fullstackmall/service/product/entity/ProductEntity.java
backend/service/src/main/java/com/example/fullstackmall/service/product/mapper/ProductMapper.java
backend/service/src/main/java/com/example/fullstackmall/service/product/service/ProductDbService.java

分类相关数据库访问主要在:

text 复制代码
backend/service/src/main/java/com/example/fullstackmall/service/category/entity/CategoryEntity.java
backend/service/src/main/java/com/example/fullstackmall/service/category/mapper/CategoryMapper.java
backend/service/src/main/java/com/example/fullstackmall/service/category/service/CategoryDbService.java

本篇解决下面几个问题:

  1. MyBatis-Plus 是什么,和 SQL、JDBC、ORM 有什么关系;
  2. Entity 如何映射数据库表;
  3. @TableName@TableId@TableField 分别解决什么问题;
  4. Mapper 为什么可以只继承 BaseMapper<ProductEntity> 就拥有常用数据库方法;
  5. ServiceImpl<ProductMapper, ProductEntity> 提供了哪些常用能力;
  6. lambdaQuery()Wrappers.lambdaQuery()LambdaQueryWrapper 如何表达查询条件;
  7. Page<ProductEntity> 如何和前端分页参数对应;
  8. DbService 和 Facade 的职责边界;
  9. 如何从 GET /api/products 一路追到 MyBatis-Plus 查询;
  10. 初学 MyBatis-Plus 最容易踩哪些坑。

学完本篇,你应该能读懂当前项目商品列表、商品详情、分类查询的大部分数据库访问代码;也能理解为什么后端不是在 Controller 里直接拼 SQL。

2. 用前端知识类比:API client、类型模型和数据访问封装

前端项目里,你可能会封装一个 API client:

ts 复制代码
// 前端侧示意代码
export async function queryProducts(params: ProductQueryRequest) {
  return http.get<ApiResponse<PageResponse<ProductSummaryResponse>>>('/api/products', { params })
}

组件不会直接写所有 Axios 细节,而是调用封装好的函数。这样组件只关心"我要商品列表",不关心 baseURL、拦截器、错误处理、响应解析。

后端访问数据库也类似。Controller 不应该直接写 SQL,Facade 不应该到处拼接数据库条件。当前项目把数据库访问集中到 ProductDbService,它再依赖 ProductMapperProductMapper 基于 MyBatis-Plus 操作 ProductEntity 对应的 mall_product 表。

可以这样类比:

前端概念 后端 MyBatis-Plus 概念 当前项目例子
TypeScript interface Java Entity / DTO ProductEntityProductQueryRequest
Axios API client Mapper / DbService ProductMapperProductDbService
请求参数对象 QueryWrapper 条件 LambdaQueryWrapper<ProductEntity>
分页参数 Page<>(pageNum, pageSize) queryPage(...)
response data Entity / Response DTO Page<ProductEntity>PageResponse<ProductSummaryResponse>
组件调用 service Controller / Facade 调 DbService ProductFacadeproductDbService

但有一个重要区别:前端 API client 是调用远程 HTTP,后端 Mapper 是操作数据库。前端请求失败多是网络、权限或接口错误;后端数据库操作失败可能涉及 SQL 语法、表结构、索引、约束、事务、连接池、并发锁。

本章先把 MyBatis-Plus 当成一个"数据库 API client 生成器"来理解。它根据 Entity 和 Mapper,帮我们提供很多常见 CRUD 方法,让我们少写重复 SQL,同时仍然保留 SQL 思维。

3. 后端核心概念讲解

3.1 JDBC、MyBatis、MyBatis-Plus 的关系

先不要被名字吓到,可以分层理解:

text 复制代码
JDBC           = Java 访问数据库的底层标准接口
MyBatis        = 帮你写 SQL 映射和结果映射的持久层框架
MyBatis-Plus   = 在 MyBatis 基础上增强常用 CRUD、分页、Wrapper 等能力

如果把数据库访问比作前端发请求:JDBC 像最底层的 XMLHttpRequestfetch 能力;MyBatis 像你封装了一层请求和响应映射;MyBatis-Plus 像在这层基础上又提供了常用 CRUD 工具函数、分页插件、条件构造器。

MyBatis-Plus 不等于"不需要懂 SQL"。它能帮你少写样板代码,但你仍然要知道它生成的查询大概对应什么 SQL。否则你看到 wrapper.eq(...).like(...).orderByDesc(...) 时,只会觉得像魔法。

3.2 Entity:数据库表在 Java 里的映射对象

当前商品 Entity 是:

java 复制代码
@Data
@TableName("mall_product")
public class ProductEntity {

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @TableField("category_id")
    private Long categoryId;

    private String title;
    private String subtitle;
    private String description;

    @TableField("status_code")
    private String statusCode;

    @TableField("created_by")
    private Long createdBy;

    @TableField("created_at")
    private LocalDateTime createdAt;

    @TableField("updated_at")
    private LocalDateTime updatedAt;
}

它对应第 4 篇讲过的 mall_product 表。Entity 是"持久化对象",主要面向数据库,不是给前端直接看的 Response,也不是前端可以随便传的 Request。

@Data 来自 Lombok,帮我们生成 getter、setter、toString 等方法。你在代码里看到 product.getTitle()product.setStatusCode(...),这些方法不是手写的,而是 Lombok 在编译阶段生成的。

@TableName("mall_product") 告诉 MyBatis-Plus:这个 Java 类对应数据库里的 mall_product 表。如果没有这个注解,框架可能根据类名推导表名,但真实项目最好显式标明,阅读更清楚。

3.3 @TableId:主键如何映射

商品表主键是:

sql 复制代码
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT
PRIMARY KEY (id)

Entity 中对应:

java 复制代码
@TableId(value = "id", type = IdType.AUTO)
private Long id;

value = "id" 表示数据库列名是 idtype = IdType.AUTO 表示主键由数据库自增生成。创建商品时,代码先创建一个 ProductEntity,调用 productDbService.save(product),MyBatis-Plus 插入数据库后会把生成的 ID 回填到 product.id

这解释了第三篇提到的事情:创建商品 Request DTO 里不应该有 id。因为 ID 不是外部输入,而是数据库生成的可信身份。

前端类比:列表渲染的 key 应该来自后端稳定 ID,而不是数组下标。数据库主键就是这个稳定 ID 的源头。

3.4 @TableField 与下划线转驼峰

数据库字段常用下划线:category_idstatus_codecreated_at。Java 字段常用驼峰:categoryIdstatusCodecreatedAt

Entity 中有:

java 复制代码
@TableField("category_id")
private Long categoryId;

@TableField("status_code")
private String statusCode;

同时,当前 application.yml 里配置:

yaml 复制代码
mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true

这表示 MyBatis-Plus 支持下划线到驼峰的映射。显式 @TableField 能让字段对应关系更清晰,尤其适合初学者读代码。

不要把数据库字段名和 Java 字段名混乱使用。写 SQL 时看数据库列名,写 Java Lambda Wrapper 时看 Java getter,例如 ProductEntity::getStatusCode。MyBatis-Plus 会根据映射关系生成对应 SQL。

3.5 Mapper:表操作入口

当前商品 Mapper 很短:

java 复制代码
public interface ProductMapper extends BaseMapper<ProductEntity> {
}

第一次看到你可能会疑惑:里面什么方法都没有,怎么查数据库?关键在 BaseMapper<ProductEntity>。MyBatis-Plus 提供了通用方法,例如按 ID 查询、插入、更新、删除、按条件查询等。ProductMapper 继承后,就拥有了操作 ProductEntity 对应表的基础能力。

前端类比:你引入一个通用 CRUD client,只要告诉它资源类型是 Product,它就能提供 getByIdlistsave 等方法。不同的是,后端 Mapper 操作的是数据库表,不是 HTTP 资源。

Mapper 接口本身如何被 Spring 发现?当前项目有配置:

java 复制代码
@Configuration
@MapperScan("com.example.fullstackmall.service.**.mapper")
public class MybatisPlusConfig { ... }

@MapperScan 会扫描 mapper 包下的接口,为它们创建代理对象。你没有看到 new ProductMapper(),是因为 Spring 和 MyBatis-Plus 在运行时生成并注入了 Mapper 代理。

3.6 DbService:数据库访问服务

当前商品数据库访问服务是:

java 复制代码
@Service
public class ProductDbService extends ServiceImpl<ProductMapper, ProductEntity> {
    ...
}

ServiceImpl<ProductMapper, ProductEntity> 是 MyBatis-Plus 提供的通用 Service 实现。它内部持有 baseMapper,并提供 getByIdsaveupdateByIdpagelambdaQuerylistByIds 等方法。

为什么项目还要写 ProductDbService,而不是在 Facade 里直接用 Mapper?因为 DbService 可以封装数据库访问相关规则:

  • 查不到商品就抛业务异常;
  • 公开查询强制限定 ON_SALE
  • 后台查询允许按请求状态筛选;
  • 查询条件统一 trim;
  • 分页排序统一;
  • 更新副标题时只更新指定字段;
  • 把数据库层异常转换为更稳定的业务语义。

DbService 不是 Controller,也不是 Facade。它关注"如何访问数据库",而不是"一个业务用例完整流程是什么"。

3.7 Facade、DbService、Mapper 的边界

当前商品创建链路大致是:

sequenceDiagram participant C as ProductAdminController participant F as ProductFacade participant Cat as CategoryDbService participant DB as ProductDbService participant M as ProductMapper/BaseMapper participant MySQL as MySQL mall_product C->>F: createProduct(request) F->>Cat: requireEnabledCategory(categoryId) F->>F: 组装 ProductEntity F->>DB: save(product) DB->>M: BaseMapper insert M->>MySQL: INSERT INTO mall_product ... MySQL-->>M: 生成自增 id M-->>DB: 回填 ProductEntity.id DB-->>F: 保存完成 F-->>C: ProductDetailResponse

Controller 负责 HTTP;Facade 负责编排业务;DbService 负责数据库访问规则;Mapper 负责把 Java 操作落到 MySQL。这个分层对前端同学很重要,因为你可能习惯在一个页面 service 函数里完成很多事情,但后端业务复杂后必须拆分职责。

例如创建商品时,Facade 要校验分类是否启用、获取当前管理员、设置商品状态、保存商品、删除缓存、组装响应。这些不是单纯数据库查询,所以不应该全部放到 Mapper 或 DbService。

3.8 Wrapper:用 Java 表达 SQL 条件

ProductDbService.requirePublishedById 是:

java 复制代码
public ProductEntity requirePublishedById(Long productId) {
    ProductEntity product = lambdaQuery()
            .eq(ProductEntity::getId, productId)
            .eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name())
            .one();
    if (product == null) {
        throw productNotFound();
    }
    return product;
}

它大致对应 SQL:

sql 复制代码
SELECT *
FROM mall_product
WHERE id = ?
  AND status_code = 'ON_SALE'
LIMIT 1;

eq 是 equals,表示等值条件。ProductEntity::getId 是 Java 方法引用,MyBatis-Plus 通过它知道你要操作 id 字段。one() 表示期望查出一条记录。

前端类比:

ts 复制代码
// 前端侧示意代码
const product = products.find(item =>
  item.id === productId && item.statusCode === 'ON_SALE'
)

但数据库查询不是前端数组查询。前端数组数据已经在内存里,数据库查询会生成 SQL 发给 MySQL,MySQL 再利用索引和执行计划查数据。

3.9 分页查询:PageLambdaQueryWrapper

queryPage 是本章最值得细读的方法:

java 复制代码
private Page<ProductEntity> queryPage(ProductQueryRequest request, ProductStatus forcedStatus) {
    LambdaQueryWrapper<ProductEntity> wrapper = Wrappers.lambdaQuery(ProductEntity.class);
    String keyword = normalizeKeyword(request.getKeyword());
    if (StringUtils.hasText(keyword)) {
        wrapper.and(condition -> condition
                .like(ProductEntity::getTitle, keyword)
                .or()
                .like(ProductEntity::getDescription, keyword));
    }
    if (request.getCategoryId() != null) {
        wrapper.eq(ProductEntity::getCategoryId, request.getCategoryId());
    }
    if (forcedStatus != null) {
        wrapper.eq(ProductEntity::getStatusCode, forcedStatus.name());
    }
    wrapper.orderByDesc(ProductEntity::getCreatedAt)
            .orderByDesc(ProductEntity::getId);
    return page(new Page<>(request.getPageNum(), request.getPageSize()), wrapper);
}

它做了几件事:

  1. 创建一个查询条件 wrapper;
  2. 对关键字做 trim;
  3. 如果关键字非空,就按标题或描述模糊查询;
  4. 如果分类 ID 非空,就按分类过滤;
  5. 如果强制状态非空,就按状态过滤;
  6. 按创建时间倒序,再按 ID 倒序;
  7. 根据 pageNumpageSize 执行分页查询。

这段 Wrapper 大致对应 SQL:

sql 复制代码
SELECT *
FROM mall_product
WHERE (title LIKE CONCAT('%', ?, '%') OR description LIKE CONCAT('%', ?, '%'))
  AND category_id = ?
  AND status_code = ?
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?;

实际 SQL 会由 MyBatis-Plus 和分页插件生成,参数也会根据哪些条件存在而变化。比如没有 keyword,就不会有 LIKE 条件;没有 categoryId,就不会有分类条件。

这和前端对象过滤很像,但要注意 SQL 的条件组合。wrapper.and(condition -> condition.like(...).or().like(...)) 是为了把标题和描述的 OR 包在一组括号里。否则复杂条件很容易出现逻辑优先级错误。

3.10 公开查询为什么强制 ON_SALE

ProductDbService 有两个方法:

java 复制代码
public Page<ProductEntity> queryAdminPage(ProductQueryRequest request) {
    return queryPage(request, request.getStatus());
}

public Page<ProductEntity> queryPublishedPage(ProductQueryRequest request) {
    return queryPage(request, ProductStatus.ON_SALE);
}

后台查询使用请求中的状态条件,管理员可以查看草稿、上架、下架。公开查询强制状态为 ON_SALE,不能让客户端通过 query 参数绕过商品可见性规则。

这正是前端转后端必须理解的安全边界。前端页面可以不展示草稿商品入口,但用户可以手写 URL:

bash 复制代码
curl 'http://localhost:8080/api/products?status=DRAFT'

如果后端直接相信 request.getStatus(),匿名用户就可能查到草稿商品。当前项目通过 queryPublishedPage(request) 把状态强制为 ON_SALE,这是正确的后端兜底。

3.11 更新:LambdaUpdateWrapper

updateSubtitle 方法是:

java 复制代码
public ProductEntity updateSubtitle(Long productId, String subtitle) {
    LocalDateTime now = LocalDateTime.now();

    LambdaUpdateWrapper<ProductEntity> wrapper = Wrappers.lambdaUpdate(ProductEntity.class)
            .eq(ProductEntity::getId, productId)
            .set(ProductEntity::getSubtitle, subtitle)
            .set(ProductEntity::getUpdatedAt, now);

    int affectedRows = baseMapper.update(null, wrapper);
    if (affectedRows == 0) {
        throw productNotFound();
    }
    return requireById(productId);
}

它大致对应:

sql 复制代码
UPDATE mall_product
SET subtitle = ?, updated_at = ?
WHERE id = ?;

这里没有先查出整个 Entity 再保存,而是用 update wrapper 只更新副标题和更新时间。affectedRows 表示影响行数。如果为 0,说明没有这个商品 ID,于是抛 PRODUCT_NOT_FOUND

前端类比:修改一个对象的局部字段,不一定要把整个对象重新提交。PATCH 接口通常也是局部更新。后端数据库更新也可以只更新必要列,减少误改其他字段的风险。

3.12 分页插件和乐观锁插件

当前 MybatisPlusConfig 注册了两个插件:

java 复制代码
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));

分页插件会帮助 page(...) 方法生成 MySQL 分页 SQL,例如 LIMIT。没有分页插件,Page 参数可能无法按预期生效。

乐观锁插件用于根据 @Version 生成版本条件。当前商品表没有 version 字段,但 SKU 表有 version,后续库存并发章节会讲乐观锁如何防止并发覆盖。这里先知道:MyBatis-Plus 插件是通过拦截 SQL 执行过程增强功能,不是业务代码里手写每一条 SQL。

@MapperScan("com.example.fullstackmall.service.**.mapper") 则负责扫描 Mapper。没有扫描,Spring 容器里就没有 Mapper Bean,DbService 也无法正常使用底层 Mapper。

3.13 常用 CRUD 方法先建立词汇表

MyBatis-Plus 的 API 很多,初学阶段不需要一次背完。你先把当前项目可能遇到的常用词汇表建立起来就够了。

save(entity) 通常对应插入一条记录。创建商品时,Facade 组装 ProductEntity,再调用 productDbService.save(product),最终写入 mall_product。如果主键策略是自增,插入成功后数据库生成的 ID 会回填到 Entity。前端类比就是提交表单后,后端返回带 ID 的新对象。

getById(id) 对应按主键查一条。ProductDbService.requireById 就是在 getById 外面加了一层"查不到抛业务异常"。很多后端团队都会封装 requireXxx 方法,因为业务代码经常需要"必须存在,否则报错"的语义。

updateById(entity) 对应按 Entity 主键更新。它适合你已经拿到完整 Entity,并且明确要按 ID 保存当前对象状态的场景。当前商品状态变更中,先查出商品,再修改 statusCodeupdatedAt,然后调用 updateById(product)

baseMapper.update(null, wrapper) 对应按条件局部更新。当前修改副标题接口只更新 subtitleupdated_at,并用 id 作为条件。这种方式不会把整个 Entity 的所有字段都提交给数据库,适合 PATCH 类局部修改。

listByIds(ids) 对应按一批 ID 查询多条。CategoryDbService.findCategoryMap 使用它批量查分类,避免 N+1。前端类比就是先收集所有 categoryId,再一次性请求字典,而不是每个商品单独请求一次。

page(page, wrapper) 对应分页查询。它不仅查当前页记录,还会提供 total、pages 等分页元信息。前端分页组件需要的不只是列表,还需要总数和页数,后端分页对象就是为这个场景服务的。

lambdaQuery()Wrappers.lambdaQuery(...) 都用于构造查询条件。你可以先把它们理解成"SQL WHERE / ORDER BY 的 Java 表达"。等你熟悉后,再学习更复杂的 select、group、exists、自定义 SQL。

3.14 为什么推荐 Lambda Wrapper,而不是字符串字段名

MyBatis-Plus 也支持用字符串写字段名,例如某些写法可能是 eq("status_code", "ON_SALE")。但当前项目大量使用 ProductEntity::getStatusCode 这种 Lambda 方法引用。它的好处是:

  1. 字段重命名时更容易被 IDE 和编译器发现;
  2. 不容易把数据库列名、Java 字段名写混;
  3. 阅读时能直接跳转到 Entity 字段;
  4. 和 Java 类型系统结合更紧密;
  5. 对前端转后端同学更友好,因为它像 TypeScript 里的类型安全访问。

前端类比:如果你在 TypeScript 里写 item.statusCode,字段不存在时编译器会提醒;如果你写字符串 'status_code',拼错了可能运行时才发现。后端也是同样道理。Lambda Wrapper 不是绝对避免所有错误,但它比到处写字符串更安全。

当然,SQL 最终仍然使用数据库列名。ProductEntity::getStatusCode 只是 Java 侧表达,MyBatis-Plus 会根据映射关系转换成 status_code。所以你要同时熟悉两套命名:Java 代码里看驼峰,SQL 表里看下划线。

3.15 one()list()page() 的语义差异

初学者经常把查询结果类型弄混。one() 表示期望一条记录,返回 Entity 或 null。适合按主键、唯一字段、强唯一条件查询。比如公开商品详情按 ID 和状态查,期望最多一条。

list() 表示返回多条记录,结果是 List<Entity>。适合按一组条件查所有匹配记录,但要小心数据量。如果条件范围很大,直接 list() 可能拉出大量数据。后端列表接口通常不要直接 list 全部,而要分页。

page() 表示分页查询,返回 Page<Entity>。它同时包含 records、total、current、size、pages 等信息。前端表格分页、商品列表、订单列表都应该优先考虑 page,而不是 list。

这和前端 API 设计也一致:详情接口返回一个对象,字典接口可能返回一个小列表,业务列表接口通常返回分页对象。不同返回类型表达不同业务预期。

3.16 DbService 中抛业务异常是否合理

你可能会问:ProductDbService 是数据库访问层,为什么会抛 BusinessException?这涉及分层取舍。严格分层下,持久层可能只返回 null 或底层异常,由业务层转换。但当前项目的 DbService 不只是裸 Mapper,它是"数据库访问 Service",封装了和数据库查询紧密相关的业务语义,例如商品不存在、分类不存在、公开商品必须上架。

这样的好处是调用方更简单:Facade 可以调用 requireByIdrequirePublishedByIdrequireEnabledCategory,不用每次重复写 null 判断和错误码。坏处是 DbService 需要知道一些业务错误码。当前项目规模适中,这种取舍是可以接受的。

前端类比:你可以在底层 fetch 只返回原始 Response,也可以在 API client 中把 404 转成 ProductNotFoundError。只要团队约定清楚,关键是不要让同一种错误在不同地方用不同方式表达。

3.17 Entity 字段为什么有时是 String,有时是枚举

ProductEntity.statusCodeString,而 ProductDetailResponse.statusProductStatus 枚举。数据库表里 status_codeVARCHAR(20),所以 Entity 直接用 String 保存数据库编码。对外响应时,Facade 用 ProductStatus.valueOf(product.getStatusCode()) 转成枚举。

这种设计让数据库存储和接口表达分开。数据库保存稳定编码,Java 业务层可以用枚举提升可读性和类型安全。你以后也可能看到另一种做法:Entity 字段直接使用枚举,并通过 TypeHandler 映射数据库字符串。两种都可以,关键是团队统一。

初学阶段先记住:数据库状态字段通常是字符串编码或数字编码;Java 业务代码最好不要到处散落魔法字符串;对外接口要给前端稳定、可理解的状态值。

3.18 从前端 filter 思维升级到数据库条件思维

前端同学很容易把查询想成数组过滤:先拿全部商品,再 filtersortslice。在小数据量演示里这当然能工作,但后端不能这么做。后端查询应该尽量让数据库完成过滤、排序、分页,只把当前页必要数据传回 Java。

原因很简单:数据库可能有几十万、几百万条商品或订单。把全部数据查到 Java 内存再过滤,会浪费网络、内存和 CPU,还可能拖垮服务。SQL 的 WHEREORDER BYLIMIT,以及索引,都是为了让数据在数据库层就被缩小范围。

所以你读 queryPage 时,不要只看 Java 链式 API,要在脑子里翻译成 SQL:哪些条件进入 WHERE,哪些字段用于 ORDER BY,分页如何 LIMIT,哪些索引可能被用上。这个能力是从前端转后端的关键分水岭。

4. 在本项目中对应哪些文件

文件 本章关注点
backend/service/src/main/java/com/example/fullstackmall/service/config/MybatisPlusConfig.java Mapper 扫描、分页插件、乐观锁插件
backend/service/src/main/resources/application.yml 下划线转驼峰、全局主键策略配置
backend/service/src/main/java/com/example/fullstackmall/service/product/entity/ProductEntity.java mall_product 表对应 Entity
backend/service/src/main/java/com/example/fullstackmall/service/product/mapper/ProductMapper.java 商品 Mapper,继承 BaseMapper
backend/service/src/main/java/com/example/fullstackmall/service/product/service/ProductDbService.java 商品数据库访问封装、查询条件、分页、更新
backend/service/src/main/java/com/example/fullstackmall/service/category/entity/CategoryEntity.java mall_category 表对应 Entity
backend/service/src/main/java/com/example/fullstackmall/service/category/mapper/CategoryMapper.java 分类 Mapper
backend/service/src/main/java/com/example/fullstackmall/service/category/service/CategoryDbService.java 分类查询、启用校验、批量查 Map
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductFacade.java 业务层如何调用 DbService 并组装 Response DTO
sql/01_schema.sql Entity 字段对应的真实数据库表结构

建议你一边打开 SQL,一边打开 Entity,对照字段读。不要孤立看 Java 类,也不要孤立看 SQL 表。

5. Mermaid 图:MyBatis-Plus 分层关系

flowchart TD A[Controller\nHTTP 参数和响应] --> B[Facade\n业务编排] B --> C[DbService\n数据库访问规则] C --> D[ServiceImpl\nMyBatis-Plus 通用 Service] D --> E[Mapper\nBaseMapper 代理] E --> F[Entity\nJava 持久化对象] F --> G[MySQL 表\nmall_product] H[MybatisPlusConfig] --> E H --> I[分页插件] H --> J[乐观锁插件] K[application.yml] --> L[下划线转驼峰] L --> F

这张图的重点是:MyBatis-Plus 不应该直接出现在 Controller 的思维里。Controller 面向 HTTP;Entity / Mapper 面向数据库;Facade 负责把这些层连接成业务用例。

6. 逐段读源码

6.1 读 ProductEntity

ProductEntity 的类注释写着"mall_product 表对应的持久化对象"。这句话已经说明它的定位:它不是 Request,也不是 Response,而是数据库表的 Java 表达。

@TableName("mall_product") 对应第 4 篇的商品表。字段 categoryId 对应 category_idstatusCode 对应 status_codecreatedBy 对应 created_by。标题、描述、副标题等字段因为名称较简单,可以依靠默认映射。

这里有一个重要注释:商品标题是"可变的当前值,不是订单中的历史快照"。这和第 4 篇订单项快照对应。Entity 代表当前商品表,而不是历史订单事实。后续订单项会有自己的 Entity。

createdBy 的注释强调"来自可信登录态,而不是前端任意指定"。这再次连接第三篇的 Request DTO 边界:外部输入不能随便决定数据库字段。

6.2 读 ProductMapper

ProductMapper 只有一行继承:

java 复制代码
public interface ProductMapper extends BaseMapper<ProductEntity> {
}

这就是 MyBatis-Plus 最常见的写法。只要 Entity 和表映射正确,通用 Mapper 就能提供基础 CRUD。项目后续如果需要复杂 SQL,可以在 Mapper 中新增自定义方法,配合注解或 XML。但当前商品基础查询大量使用 MyBatis-Plus Wrapper,不需要手写 Mapper 方法。

初学者不要因为 Mapper 为空就忽略它。它是 MyBatis-Plus 生成数据库代理的入口,也是 DbService 的泛型参数之一。没有 Mapper,就没有底层表操作能力。

6.3 读 ProductDbService.requireById

java 复制代码
public ProductEntity requireById(Long productId) {
    ProductEntity product = getById(productId);
    if (product == null) {
        throw productNotFound();
    }
    return product;
}

getById 来自 ServiceImpl。它根据主键查询一条记录。这个方法额外做了"查不到就抛业务异常"的封装。

为什么不让调用方自己判断 null?因为商品不存在是一个稳定业务场景,应该统一转换为 PRODUCT_NOT_FOUND,最终由 GlobalExceptionHandler 返回给前端。把这个规则放在 DbService 里,Facade 调用时更简单。

前端类比:API client 可以把某些通用错误转成统一错误对象,而不是让每个组件都写一遍相同判断。

6.4 读 requirePublishedById

java 复制代码
ProductEntity product = lambdaQuery()
        .eq(ProductEntity::getId, productId)
        .eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name())
        .one();

它不仅按 ID 查,还要求状态是 ON_SALE。公开商品详情调用的是这个方法,所以草稿或下架商品即使数据库里存在,也不会被公开接口返回。

这段代码非常适合作为 MyBatis-Plus 入门例子:

  • lambdaQuery() 创建查询;
  • eq 添加等值条件;
  • 方法引用避免硬编码字符串字段名;
  • one() 执行并返回一条;
  • 查不到转业务异常。

对应 SQL 心智模型是:

sql 复制代码
SELECT * FROM mall_product
WHERE id = ? AND status_code = 'ON_SALE'
LIMIT 1;

6.5 读 queryAdminPagequeryPublishedPage

这两个方法共用 queryPage,但传入状态不同:

java 复制代码
public Page<ProductEntity> queryAdminPage(ProductQueryRequest request) {
    return queryPage(request, request.getStatus());
}

public Page<ProductEntity> queryPublishedPage(ProductQueryRequest request) {
    return queryPage(request, ProductStatus.ON_SALE);
}

这就是同一套数据库查询条件在不同业务入口下的差异。后台入口相信管理员可以按状态筛选;公开入口强制上架状态。

不要把这个逻辑放给前端处理。前端可以隐藏状态筛选器,但不能阻止用户手动拼参数。后端通过 DbService 强制条件,才是真正安全。

6.6 读 queryPage 的关键字条件

java 复制代码
String keyword = normalizeKeyword(request.getKeyword());
if (StringUtils.hasText(keyword)) {
    wrapper.and(condition -> condition
            .like(ProductEntity::getTitle, keyword)
            .or()
            .like(ProductEntity::getDescription, keyword));
}

normalizeKeyword 会 trim,StringUtils.hasText 判断是否有有效文本。只有关键字非空才加模糊查询条件。and(... like title or like description ...) 表示标题或描述包含关键字。

前端类比:

ts 复制代码
// 前端侧示意代码
const keyword = form.keyword?.trim()
if (keyword) {
  list = list.filter(item =>
    item.title.includes(keyword) || item.description?.includes(keyword)
  )
}

但后端不要真的把全部商品查出来再用 Java 过滤,而是把条件交给数据库。数据库有索引、分页、执行计划,适合做数据筛选。

6.7 读分类条件和状态条件

java 复制代码
if (request.getCategoryId() != null) {
    wrapper.eq(ProductEntity::getCategoryId, request.getCategoryId());
}
if (forcedStatus != null) {
    wrapper.eq(ProductEntity::getStatusCode, forcedStatus.name());
}

这两个条件都只有在值存在时才添加。它体现了动态查询:同一个接口可以支持可选筛选条件。用户不选分类,就不按分类过滤;公开查询一定有 ON_SALE;后台查询只有传状态时才按状态过滤。

动态查询是后端列表接口常见需求。MyBatis-Plus Wrapper 的优势就是让动态条件比手写字符串 SQL 更安全、更易读。

6.8 读排序和分页

java 复制代码
wrapper.orderByDesc(ProductEntity::getCreatedAt)
        .orderByDesc(ProductEntity::getId);
return page(new Page<>(request.getPageNum(), request.getPageSize()), wrapper);

createdAt 倒序,最新商品在前;再按 id 倒序,是为了在创建时间相同的情况下有稳定排序。分页查询如果排序不稳定,用户翻页时可能看到重复或遗漏记录。

new Page<>(request.getPageNum(), request.getPageSize()) 对应前端传来的分页参数。MyBatis-Plus 的分页插件会把它转换为数据库分页 SQL,并返回 Page<ProductEntity>,里面有当前页、每页大小、总数、总页数、记录列表。

随后 ProductFacade.toPageResponse(...) 会把 Page<ProductEntity> 转成对外的 PageResponse<ProductSummaryResponse>。这一步很重要:Entity 不直接返回给前端。

6.9 读分类 DbService 的批量查询

CategoryDbService.findCategoryMap

java 复制代码
public Map<Long, CategoryEntity> findCategoryMap(Collection<Long> categoryIds) {
    if (categoryIds == null || categoryIds.isEmpty()) {
        return Collections.emptyMap();
    }
    return listByIds(categoryIds).stream()
            .collect(Collectors.toMap(CategoryEntity::getId, Function.identity()));
}

ProductFacade.toPageResponse(...) 会先从商品列表中收集所有分类 ID,然后一次性查询分类 Map,再组装响应。这是为了避免 N+1 查询。

什么是 N+1?假设商品列表有 10 条,如果每条商品都单独查一次分类,就是 1 次查商品 + 10 次查分类。数据一多,数据库压力会明显增加。批量查询分类后建 Map,就变成 1 次查商品 + 1 次查分类。

前端类比:不要在列表渲染每一行时都发一个接口查字典,而是先批量拿到字典,再用 Map 映射。后端也是同样思想。

6.10 读 Facade 如何把 Entity 转 Response

ProductFacade 里有:

java 复制代码
private ProductDetailResponse toDetailResponse(ProductEntity product, CategoryEntity category) {
    return new ProductDetailResponse(
            product.getId(),
            product.getCategoryId(),
            category.getName(),
            product.getTitle(),
            product.getSubtitle(),
            product.getDescription(),
            ProductStatus.valueOf(product.getStatusCode()),
            product.getCreatedBy(),
            product.getCreatedAt(),
            product.getUpdatedAt()
    );
}

这里完成了 Entity 到 Response DTO 的转换。categoryName 来自 CategoryEntity,商品状态从数据库字符串转换成 ProductStatus 枚举。

这一步再次说明:数据库 Entity 不等于响应对象。Response 可以组合多个 Entity,也可以隐藏内部字段,也可以把数据库编码转换成更适合前端理解的枚举。

6.11 读更新副标题的完整链路

修改副标题链路是:

text 复制代码
PATCH /api/admin/products/{id}/subtitle
→ ProductAdminController.updateSubtitle
→ ProductFacade.updateSubtitle
→ ProductDbService.updateSubtitle
→ MyBatis-Plus baseMapper.update
→ mall_product.subtitle / updated_at
→ 删除商品详情缓存
→ 重新查询分类并组装 ProductDetailResponse

这个链路很适合作为前端转后端练习。前端看到的是一个 PATCH 请求,后端实际经历了参数校验、业务编排、数据库局部更新、影响行数判断、缓存失效、响应组装。

如果更新影响行数为 0,说明商品不存在,DbService 会抛出 PRODUCT_NOT_FOUND。如果数据库更新成功,Facade 再删除缓存,避免前端下次看到旧副标题。缓存细节后面会讲,但你现在要先知道:数据库成功是事实,缓存只是加速副本。

7. 本地运行 / 如何观察 MyBatis-Plus 行为

7.1 编译确认 Entity / Mapper 没问题

修改 Entity、Mapper、DbService 后,先运行:

bash 复制代码
cd backend
mvn -pl service -am compile

Java 是静态类型语言,很多字段名、泛型、方法引用错误会在编译期暴露。比如你把 ProductEntity::getStatusCode 写错,编译就可能失败。

7.2 启动服务并请求商品列表

bash 复制代码
cd backend
mvn -pl service -am spring-boot:run -Dspring-boot.run.profiles=local

另一个终端请求:

bash 复制代码
curl 'http://localhost:8080/api/products?pageNum=1&pageSize=10&keyword=Java'

你可以把这个请求和 queryPage 对照:pageNumpageSize 进入 Pagekeyword 进入 like title or like description;公开查询强制 ON_SALE

7.3 用 SQL 验证查询条件

如果使用 MySQL,可以手写 SQL 对照:

sql 复制代码
SELECT id, title, description, status_code, created_at
FROM mall_product
WHERE status_code = 'ON_SALE'
  AND (title LIKE '%Java%' OR description LIKE '%Java%')
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 0;

如果 SQL 结果和接口不一致,优先检查 profile。local profile 使用 H2 内存数据,默认 profile 使用 MySQL。你查 MySQL,但服务连 H2,就会产生错觉。

7.4 验证详情只查上架商品

bash 复制代码
curl 'http://localhost:8080/api/products/1'
curl 'http://localhost:8080/api/products/2'

根据种子数据,商品 1 可能是上架,商品 2 可能是草稿。公开详情调用 requirePublishedById,所以草稿商品即使存在于数据库,也不应该被公开接口返回。

这就是 MyBatis-Plus 查询条件和业务规则的结合:.eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name()) 不只是技术代码,而是商品可见性规则。

7.5 验证更新副标题

管理员接口可能需要登录,后续安全章节会讲 token。这里先看 SQL 层变化。如果你通过接口或测试触发了副标题更新,可以用 SQL 验证:

sql 复制代码
SELECT id, subtitle, updated_at
FROM mall_product
WHERE id = 1;

你应该看到 subtitle 更新,updated_at 也更新。对应代码在 LambdaUpdateWrapper 的两个 .set(...)

7.6 建议的阅读和调试顺序

当你遇到一个数据库相关接口问题时,建议按固定顺序排查。第一步看 Controller,确认请求路径、method、参数位置是否正确;第二步看 Facade,确认业务入口调用了哪个 DbService 方法;第三步看 DbService,确认 Wrapper 条件、分页参数、排序字段是否符合预期;第四步看 Entity,确认 Java 字段和数据库列是否正确映射;第五步看 SQL 表结构,确认字段类型、索引、约束、默认值;第六步直接用 SQL 客户端查询数据库,确认真实数据是否存在。

这个顺序能避免你在错误层面浪费时间。例如接口查不到商品,不一定是 MyBatis-Plus 坏了,可能是公开查询强制 ON_SALE,而数据库里的商品是 DRAFT。分页结果为空,也不一定是分页插件问题,可能是你启用了 local profile,实际连的是 H2,不是你正在查看的 MySQL。更新成功但前端仍然看到旧值,也不一定是 SQL 没执行,可能是 Redis 缓存没有失效或浏览器缓存了旧响应。

前端调试有 Network、Console、组件状态、接口响应;后端调试也要有 Controller 参数、业务方法、数据库条件、真实 SQL 数据、日志 traceId。不要只盯着一层。全栈能力的核心不是会更多工具,而是能把一次请求从浏览器一路追到数据库,再从数据库结果一路解释回前端页面。如果你每次都能说清"这个字段从哪个请求来、经过哪个 Java 对象、落到哪张表、又如何被查出来返回",你的后端阅读能力就已经真正起步了。后续学习 Redis、事务和安全时,也要沿用这种证据链思维,而不是孤立背框架 API。先定位边界,再确认数据,再解释代码,最后验证结果。这就是从会调用接口走向会设计和排查后端接口的关键路径,也是一名全栈工程师必须建立的基本能力,需要在真实工程里反复持续刻意练习。

8. 常见错误

错误 1:把 Entity 直接返回给前端

Entity 面向数据库,Response DTO 面向接口契约。直接返回 Entity 会泄露内部字段,也会让数据库结构变化影响前端契约。当前项目通过 toDetailResponsetoSummaryResponse 做转换,是更好的做法。

错误 2:把 Request DTO 当 Entity 保存

前端传来的 Request DTO 是外部输入白名单,不能直接当 Entity。创建商品时,后端要补充状态、创建人、创建时间、更新时间,还要 trim 文本、校验分类,这些都不是前端能决定的。

错误 3:以为 MyBatis-Plus 可以替代 SQL 基础

Wrapper 看起来像 Java 链式调用,但最终仍然生成 SQL。你必须能把 .eq.like.orderByDescpage 翻译成大概 SQL,否则遇到性能或结果错误就无法排查。

错误 4:公开查询相信前端传入的状态

公开商品接口必须强制 ON_SALE,不能让客户端传 status=DRAFT 查草稿。当前项目通过 queryPublishedPage(request) 传入 ProductStatus.ON_SALE 做后端兜底。

错误 5:动态条件没有判断空值

如果不判断 keyword 是否有文本,就可能生成无意义的 LIKE '%%'。如果不判断 categoryId 是否为空,就可能生成错误条件。动态查询一定要清楚哪些条件可选。

错误 6:排序不稳定导致分页重复

只按创建时间排序,如果多条记录时间相同,分页可能不稳定。当前项目再按 ID 倒序,能提高稳定性。真实项目分页排序要格外注意。

错误 7:列表组装产生 N+1 查询

遍历每个商品时都查一次分类,会导致大量 SQL。当前项目先批量查询分类 Map,再组装响应,是避免 N+1 的典型做法。

错误 8:Mapper 没有被扫描

如果忘记 @MapperScan 或包路径不匹配,Mapper 代理不会被创建,启动时可能注入失败。当前项目扫描 com.example.fullstackmall.service.**.mapper

错误 9:更新后忘记缓存失效

数据库更新成功后,如果商品详情缓存还在,前端可能继续看到旧数据。当前 ProductFacade.updateSubtitle 在 MySQL 修改成功后删除缓存。缓存章节会深入讲这个问题。

错误 10:忽略影响行数

更新副标题时 baseMapper.update 返回影响行数。如果是 0,说明没有更新到任何商品,不能假装成功。当前代码会抛 PRODUCT_NOT_FOUND

9. 本章小练习

练习 1:对照 Entity 和表字段

打开:

text 复制代码
sql/01_schema.sql
backend/service/src/main/java/com/example/fullstackmall/service/product/entity/ProductEntity.java

回答:

  1. status_code 对应 Java 哪个字段?
  2. created_by 对应 Java 哪个字段?
  3. 主键 ID 由谁生成?
  4. 哪些字段没有显式 @TableField,为什么仍然能映射?

练习 2:把 Wrapper 翻译成 SQL

把下面代码翻译成你能理解的 SQL:

java 复制代码
lambdaQuery()
    .eq(ProductEntity::getId, productId)
    .eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name())
    .one();

练习 3:解释公开查询和后台查询差异

阅读 queryAdminPagequeryPublishedPage,回答:

  1. 哪个方法允许使用请求里的状态?
  2. 哪个方法强制 ON_SALE
  3. 为什么不能把这个判断交给前端?

练习 4:查找 N+1 防护

打开 ProductFacade.toPageResponse(...)CategoryDbService.findCategoryMap(...),回答:

  1. 商品列表为什么要批量查询分类?
  2. 如果每个商品单独查一次分类,会产生什么问题?
  3. 前端开发中有没有类似"列表里每行单独请求"的性能问题?

练习 5:写一个查询需求的 Wrapper 思路

假设要查询某个分类下的已上架商品,并按创建时间倒序,你会用哪些 Wrapper 条件?不用写完全正确代码,只要写出:

  • eq 哪些字段;
  • orderByDesc 哪些字段;
  • 是否需要分页。

练习 6:解释更新副标题链路

用自己的话说明:

text 复制代码
PATCH /api/admin/products/{id}/subtitle

从 Controller 到 Facade,再到 ProductDbService.updateSubtitle,最后到数据库更新和缓存删除的流程。

10. 本篇总结

这一篇我们把第 4 篇的 SQL 表结构和 Java 后端代码连接起来了。你现在应该知道:

  • MyBatis-Plus 是基于 MyBatis 的增强工具,能减少常用 CRUD 样板代码;
  • Entity 是数据库表在 Java 里的持久化对象;
  • @TableName 指定表名,@TableId 指定主键和生成策略,@TableField 指定列名映射;
  • Mapper 继承 BaseMapper<Entity> 后拥有基础数据库操作能力;
  • @MapperScan 负责扫描 Mapper 接口并创建代理;
  • DbService 继承 ServiceImpl<Mapper, Entity> 后拥有 getByIdsavepagelambdaQuery 等常用方法;
  • LambdaQueryWrapper 用 Java 链式 API 表达 SQL 条件;
  • 分页查询使用 Page<>(pageNum, pageSize),并依赖分页插件生成分页 SQL;
  • 公开商品查询必须强制 ON_SALE,不能相信前端传参;
  • Facade 负责把 Entity 转成 Response DTO,并编排分类查询、缓存失效等业务步骤;
  • 避免 N+1 查询是后端列表接口的重要性能意识。

前端转后端学习 MyBatis-Plus,最重要的不是背 API,而是建立三层映射:数据库表字段、Java Entity 字段、接口 Response 字段。只要这三层清楚,你就能沿着 Controller → Facade → DbService → Mapper → MySQL 的链路读懂大多数接口。

11. 下一章预告

下一篇是第 6 篇:登录、JWT 与 Spring Security:从前端 token 到后端认证授权链路

我们会讲:

  • 登录接口如何校验用户名和密码;
  • 密码为什么保存 BCrypt 哈希;
  • JWT 是什么,为什么前端要保存 token;
  • Spring Security Filter 如何在 Controller 前拦截请求;
  • 401 和 403 的区别;
  • 管理员接口为什么不能只靠前端隐藏按钮;
  • CurrentUserService 如何为业务代码提供可信用户 ID。

学完下一篇,你就能理解为什么本项目的管理员创建商品接口不能随便调用,以及 createdBy 为什么必须来自后端登录上下文。

相关推荐
霸道流氓气质1 小时前
SpringBoot中使用JasperReports 报表引擎 — 介绍、原理与使用实践
java·spring boot·后端
swipe1 小时前
04|(前端转后全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
前端·后端·全栈
橘子星1 小时前
别光看教程!手把手拆解 React Todo 项目,一文吃透 5 个核心概念
前端·javascript
苏三说技术1 小时前
为什么越来越多人使用WebFlux?
后端
大白要努力!1 小时前
纯前端实现 PDF 加水印工具 —— 零后端、支持中文、实时预览
前端·pdf·html
石小石Orz1 小时前
TRAE SOLO实战:实现一个桌面3D助手
前端·人工智能
蜡台1 小时前
使用 uni-popup 实现数据选择器Data-Picker
前端·javascript·html·uniapp·uni-popup·data-picker
拆房老料1 小时前
BaseMetas FileView 1.2.0 发布:Office/WPS 大文件预览与内存安全优化实测
前端·产品运营·开源软件