演示四:用 MyBatis-Plus 把图书检索接到数据库

在 Java_web_demo 完整版的基础上,把 Data.java 里那 3 本写死的书换成 MySQL 里真正的 10 条数据。 分 6 步,每步都能单独提交、单独回退。每步的答案都在对应章节里,可以直接照抄。

这一页默认显示 答案。项目代码里对应位置留的是 TODO,先自己写, 卡住了再回来对照这一章;只想看「哪里要填」而不想看答案,点右上角 只看要填哪里。

先看全景:数据是怎么一步步走到页面上的

这一步不写代码,先建立地图。搞清楚「谁负责什么」,后面 6 步才不会乱。

改造前 vs 改造后

环节改造前(静态数据)改造后(数据库)
数据在哪Data.java 里 new 出来的 ArrayListMySQL 的 book_db.book 表
有几条3 条,写死,重启也不变10 条,改库就变,重启还在
谁来取Controller 直接 Data.getData()Service 调 Mapper,Mapper 发 SQL
能不能改不能(改了重启就还原)能(借阅会写库)

一个请求要走过的 5 层

点一下任意一层,看它的职责。绿色标签的是本次新增的。

LoginFilter 登录校验过滤器 上次课
↓
BookController 接请求、调 Service、填 Model 第 5 步改
↓
BookService 拼查询条件、做业务判断 本次新增
↓
BookMapper 发 SQL 的那一层 本次新增
↓
MySQL · book 表 10 条真实数据 本次新增
点上面任意一层,这里显示它的职责。

6 步路线图

步骤改哪个文件这一步解决什么问题要填几处
1pom.xml
application.properties
加依赖、配数据源、建库建表 —— 先能连上4
2Book.java让 Book 类对上 book 表:表名、主键、无参构造器4
3BookMapper.java
TeacherApplication.java
让 MyBatis 给接口生成实现,白拿单表 CRUD1(+1 选做)
4BookService.java用 Wrapper 拼条件 —— 本节课最难的地方3
5BookController.java把 Controller 的数据来源从内存换成数据库4
6BookService / BookController / books.html借阅真正写库 + 重定向刷新3
开始之前,先记住一句话

MyBatis-Plus 不生成表,它只负责「类 ↔ 表」的映射和 SQL 的拼装。

所以第 1 步必须先手动建库建表。这里的顺序容易搞反:加完依赖就以为表已经有了。

第 1 步:先能连上数据库

这一步一行业务代码都不写。目标只有一个:应用启动时不报数据库连接错误。

这一步要做的事
  1. 执行 sql/book_db.sql,把库、表、10 条数据建出来
  2. 在 application.properties 里填 4 个值:driver、url、username、password
  3. 启动应用,确认控制台没有连接异常

① 建库建表(这一步必须先做)

SQL 脚本在 sql/book_db.sql,命令行直接跑:

终端
mysql -u root -p < sql/book_db.sql

脚本会建出 book_db 库、book 表,并插入 10 条数据。跑完应该能看到:

id name author press status 1 楚辞 屈原 中国文联出版社 0 2 纳兰词 纳兰性德 中国文联出版社 1 ... 10 边城 沈从文 北岳文艺出版社 0 共 10 行

② 填 4 个配置

src/main/resources/application.properties 填空版
# ① 驱动类名。MySQL 8 固定是这个,照抄
spring.datasource.driver-class-name=

# ② 连接地址:jdbc:mysql://主机:端口/库名?参数
spring.datasource.url=

# ③ 你本机 MySQL 的账号
spring.datasource.username=

# ④ 你本机 MySQL 的密码
spring.datasource.password=

# 这两项照抄即可
mybatis-plus.configuration.map-underscore-to-camel-case=true
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
src/main/resources/application.properties 答案
# ① 驱动类名
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# ② 连接地址(注意 serverTimezone 和 useSSL 这两个参数)
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/book_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true

# ③ 账号
spring.datasource.username=root

# ④ 密码(改成你自己的)
spring.datasource.password=123456

mybatis-plus.configuration.map-underscore-to-camel-case=true
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

顺便说清这两个 MyBatis-Plus 配置各管什么:

配置作用
map-underscore-to-camel-case表字段 press_name 自动对上实体 pressName。本表字段没有下划线,但生产库几乎都有,养成习惯
log-impl=StdOutImpl把真正执行的 SQL 打到控制台。后面每一步都要盯着它看 —— 这是理解 MyBatis-Plus 最有效的窗口
这一步的四个坑

坑 1 · 时区:url 里漏了 serverTimezone=Asia/Shanghai,启动报 The server time zone value '…' is unrecognized。

坑 2 · SSL:MySQL 8 默认要求 SSL,漏了 useSSL=false 会报 Public Key Retrieval is not allowed 或握手失败。

坑 3 · 库名:book_db 写错成 bookdb,报 Unknown database 'bookdb' —— 注意是「库不存在」,不是「表不存在」。

坑 4 · 驱动版本:MySQL 8 的库配了 5.x 的驱动 com.mysql.jdbc.Driver,报 Loading class ... deprecated 甚至连不上。5.x 的库才用那个老类名。

自检:三步都过了才算完成
  1. mysql -u root -p -e "SELECT COUNT(*) FROM book_db.book" 返回 10
  2. 启动应用,控制台出现 Started TeacherApplication,没有 Communications link failure / Access denied
  3. 浏览器打开 http://127.0.0.1:8080/ 页面正常 —— 说明应用起来了(此时还没接库,页面数据仍是旧的 3 本)

第 2 步:让 Book 认识 book 表

库和表都有了,但 MyBatis-Plus 还不知道「Book 这个类 = 哪张表」。这一步就解决这件事。

这一步要做的事
  1. import 三个注解所在的包
  2. 类上加 @TableName("book")
  3. id 上加 @TableId(type = IdType.AUTO)
  4. 补一个无参构造器 public Book() {}

① 三个注解,各管什么

写法作用不写会怎样
@TableName("book")告诉 MP 这个类对应 book 表默认按类名找 Book 表,报 Table 'book_db.Book' doesn't exist
@TableId(type = IdType.AUTO)声明主键字段,且由数据库自增生成MP 会按字段名 id 猜主键(能猜到),但插入时用雪花 ID 而不是数据库自增
public Book() {}让 MyBatis 能反射创建对象直接启动失败,见下面的坑
src/main/java/com/dashuju/teacher/pojo/Book.java 填空版
// TODO 1:import 这三个
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.IdType;

// TODO 2:给它加 @TableName("book")
@TableName("book")
public class Book {

    // TODO 3:给它加 @TableId(type = IdType.AUTO)
    @TableId(type = IdType.AUTO)
    private Integer id;

    private String name;
    private String author;
    private String press;
    private Integer status;

    // TODO 4:补无参构造器 ← 最容易忘、后果最直接的一个空
    public Book() { }

    public Book(Integer id, String name, String author, String press, Integer status) {
        ...
    }
}
src/main/java/com/dashuju/teacher/pojo/Book.java 答案
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;

@TableName("book")
public class Book {

    @TableId(type = IdType.AUTO)
    private Integer id;

    private String name;
    private String author;
    private String press;
    private Integer status;

    public Book() { }

    public Book(Integer id, String name, String author, String press, Integer status) {
        ...
    }
}
两个坑,一个报错、一个不报错

坑 1(会报错):没有无参构造器。MyBatis 从 ResultSet 取到一行后,是先反射 new 出一个空对象、再逐个调 setter 填值。这个类原来只有 5 参构造器,而 Java 的规则是「一旦自己写了构造器,就不再送你默认无参构造器」,于是启动就报 No constructor found 之类。加个 public Book() {} 就好。

坑 2(不报错,更阴):status 的类型。表里是 TINYINT,实体必须用 Integer;如果还留着 String,页面里 th:if="${book.status == '0'}" 会永远不成立 —— 不报错,表格里状态列一片空白,能查半天。

另外有两处必须同步改,否则页面会是空白:

连带改动 · 必须同步修改
// Data.java:静态数据的状态值跟着改类型
new Book(1, "楚辞", "屈原", "中国文联出版社", "0")
new Book(1, "楚辞", "屈原", "中国文联出版社", 0)

<!-- books.html:Thymeleaf 比较也要用数字 -->
<th:block th:if="${book.status == '0'}">
<th:block th:if="${book.status == 0}">
自检
  1. 编译通过
  2. 启动应用,不报 No constructor found
  3. 此时访问 /book/list 仍然是 3 本书 —— 正常,还没接库

第 3 步:一行代码换一套 CRUD

MyBatis-Plus 最省事的地方就在这一步:一个空接口,白拿 6 个方法。

这一步要做的事
  1. 新建 mapper/BookMapper.java,让它继承 BaseMapper<Book> (这一行直接给出,原因见下)
  2. 启动类 TeacherApplication 上加 @MapperScan("com.dashuju.teacher.mapper")

① 这个接口长什么样

src/main/java/com/dashuju/teacher/mapper/BookMapper.java
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.dashuju.teacher.pojo.Book;

public interface BookMapper extends BaseMapper<Book> {
    // 空的。真的什么都没有。
}

这一行直接给出,是因为第 4 步的 ServiceImpl<BookMapper, Book> 依赖它 —— BookMapper 不继承 BaseMapper,第 4 步编译就过不去。

就这一行,你得到了:

方法等价 SQL
insert(book)INSERT INTO book (...) VALUES (...)
deleteById(1)DELETE FROM book WHERE id = 1
updateById(book)UPDATE book SET ... WHERE id = ?
selectById(1)SELECT ... FROM book WHERE id = 1
selectList(wrapper) ← 本案例用这个SELECT ... FROM book WHERE ...
selectCount(wrapper)SELECT COUNT(*) FROM book WHERE ...

② 让它生效:@MapperScan

src/main/java/com/dashuju/teacher/TeacherApplication.java 填空版
// TODO:加这个注解 + import
import org.mybatis.spring.annotation.MapperScan;

@SpringBootApplication
@MapperScan("com.dashuju.teacher.mapper")
public class TeacherApplication {
    public static void main(String[] args) {
        SpringApplication.run(TeacherApplication.class, args);
    }
}
src/main/java/com/dashuju/teacher/TeacherApplication.java 答案
import org.mybatis.spring.annotation.MapperScan;

@SpringBootApplication
@MapperScan("com.dashuju.teacher.mapper")
public class TeacherApplication {
    public static void main(String[] args) {
        SpringApplication.run(TeacherApplication.class, args);
    }
}

为什么要它?因为 BookMapper 是接口、没有实现类。MyBatis 启动时会用 JDK 动态代理给它生成一个实现,但前提是要知道「去哪个包找接口」。

漏了 @MapperScan 会怎样

启动时报:

No qualifying bean of type 'com.dashuju.teacher.mapper.BookMapper' available

或者 Consider defining a bean of type ... in your configuration。

另一种写法是给每个 Mapper 接口单独加 @Mapper 注解,接口多了不如 @MapperScan 省事。

自检
  1. 编译通过
  2. 启动应用不报错(此时 Mapper 已注册,但还没有任何地方调用它,所以看不到 SQL)
  3. 故意把 @MapperScan 注释掉再启动,看一眼那个 No qualifying bean 报错

第 4 步:用 Wrapper 拼查询条件(本课最难)

前面三步都是「配置和骨架」。从这里开始写真正的逻辑 —— 也是最容易写错的地方。

这一步要做的事
  1. import LambdaQueryWrapper
  2. listAll():查全部,按 id 升序
  3. search(name, author):名称 或 作者模糊匹配

① 先看最简单的那个:查全部

一个什么都不加的 Wrapper,就等于 SELECT * FROM book:

BookService.listAll() 答案
public List<Book> listAll() {
    return list(new LambdaQueryWrapper<Book>().orderByAsc(Book::getId));
}
==> Preparing: SELECT id,name,author,press,status FROM book ORDER BY id ASC ==> Parameters:

上面是控制台输出,由 mybatis-plus.configuration.log-impl 这个配置打印。

② 关键:AND 还是 OR

需求是「名称包含关键字 或 作者包含关键字」。但 Wrapper 默认用 AND 连接,这是最容易写错的地方。

写法生成的 SQL结果
qw.like(Book::getName, name)
  .like(Book::getAuthor, author)
WHERE name LIKE ? AND author LIKE ? 错。变成「与」了:名称含「西」且作者含「余华」→ 0 条
qw.like(Book::getName, name)
  .or().like(Book::getAuthor, author)
WHERE name LIKE ? OR author LIKE ? 能查出来,但没有括号,将来再加条件就串错
qw.and(w -> { … })
里面用 .or() 连
WHERE ((name LIKE ? OR author LIKE ?)) 对。括号把这一段包住,安全
为什么括号这么重要

假设以后要加一个条件「只查可借阅的」:

WHERE name LIKE ? OR author LIKE ? AND status = 0

因为 AND 优先级比 OR 高,它会被解释成:
name LIKE ? OR (author LIKE ? AND status = 0)

结果是「名字里带西的书,不管借没借出,全都查出来了」。加括号就是避免这个。

BookService.search() 填空版
public List<Book> search(String name, String author) {
    LambdaQueryWrapper<Book> qw = new LambdaQueryWrapper<>();

    boolean hasName = StringUtils.hasText(name);
    boolean hasAuthor = StringUtils.hasText(author);

    if (hasName || hasAuthor) {
                // TODO:这里要写成 qw.and(w -> { ... })
                // 里面:只有 name 就 w.like(Book::getName, name)
                //       只有 author 就 w.like(Book::getAuthor, author)
                //       两个都有就中间补 w.or()
    }

        // TODO:qw.orderByAsc(Book::getId); return list(qw);
    return null;
}
BookService.search() 答案
public List<Book> search(String name, String author) {
    LambdaQueryWrapper<Book> qw = new LambdaQueryWrapper<>();

    boolean hasName = StringUtils.hasText(name);
    boolean hasAuthor = StringUtils.hasText(author);

    if (hasName || hasAuthor) {
        qw.and(w -> {                        // ← 括号由此而来
            if (hasName) {
                w.like(Book::getName, name);
            }
            if (hasAuthor) {
                if (hasName) {
                    w.or();                  // ← OR 由此而来
                }
                w.like(Book::getAuthor, author);
            }
        });
    }

    qw.orderByAsc(Book::getId);
    return list(qw);
}

③ 三种输入,生成的 SQL 分别长这样

// 条件都为空 SELECT id,name,author,press,status FROM book ORDER BY id ASC → 10 行 // 只填作者「余华」 SELECT id,name,author,press,status FROM book WHERE ((author LIKE ?)) ORDER BY id ASC Parameters: %余华%(String) → 2 行:活着、许三观卖血记 // 名称「西」+ 作者「余华」 ← 这就是 OR 生效的样子 SELECT id,name,author,press,status FROM book WHERE ((name LIKE ? OR author LIKE ?)) ORDER BY id ASC Parameters: %西%(String), %余华%(String) → 3 行:西游记、活着、许三观卖血记

按错误写法写,第二条会变成 WHERE name LIKE ? AND author LIKE ?,结果只有 0 行。

自检
  1. 编译通过
  2. 看控制台 SQL:两个条件都填时,必须同时看到 (( 两个左括号 和 OR
  3. 名称「西」+ 作者「余华」应该查出 3 行,不是 1 行(AND)也不是 0 行

第 5 步:让 Controller 从数据库取数据

这一步的看点是:Controller 的结构几乎不用改。换数据源之所以这么轻松,是因为中间隔了一层 Service。

这一步要做的事
  1. import BookService 和 @Autowired
  2. 注入 BookService
  3. /book/list 里把 listAll() 的结果放进 model,键名 "books"
  4. /book/search 里把 search() 的结果放进 model,键名同样是 "books"

动手之前,先把旧的静态数据字段删掉 —— 就两行:

已删除 · BookController
ArrayList<Book> books = Data.getData();
import com.dashuju.teacher.pojo.Data;
BookController 填空版
// TODO 1:import BookService 和 @Autowired

@Controller
@RequestMapping("book")
public class BookController {

    // TODO 2:注入 BookService
    // @Autowired private BookService bookService;

    @RequestMapping("list")
    public String findBook(Model model, HttpSession session) {
        session.setAttribute("user", new User("zhangsan", "ADMIN"));   // 别删,模板要用
        // TODO 3:model.addAttribute("books", bookService.listAll());
        return "books";
    }

    @RequestMapping("search")
    public String searchBook(Book book, Model model, HttpSession session) {
        session.setAttribute("user", new User("zhangsan", "ADMIN"));
        // TODO 4:model.addAttribute("books", bookService.search(book.getName(), book.getAuthor()));
        return "books";
    }
}
BookController 答案
import com.dashuju.teacher.service.BookService;
import org.springframework.beans.factory.annotation.Autowired;

@Controller
@RequestMapping("book")
public class BookController {

    @Autowired
    private BookService bookService;

    @RequestMapping("list")
    public String findBook(Model model, HttpSession session) {
        session.setAttribute("user", new User("zhangsan", "ADMIN"));
        model.addAttribute("books", bookService.listAll());
        return "books";
    }

    @RequestMapping("search")
    public String searchBook(Book book, Model model, HttpSession session) {
        session.setAttribute("user", new User("zhangsan", "ADMIN"));
        model.addAttribute("books", bookService.search(book.getName(), book.getAuthor()));
        return "books";
    }
}
这一步的两个坑

坑 1 · 键名写错。Model 键名必须是 "books",因为 books.html 里是 th:each="book : ${books}"。写成 "bookList" 不会报错,页面只是渲染出一张空表。

坑 2 · 忘了登录。/book/** 不在 LoginFilter 的白名单里,直接访问会被 302 打回登录页。测试前先访问 /dologin?username=tom&password=123。

自检
  1. 登录后访问 /book/list,能看到 10 本书(不再是 Data 里那 3 本)
  2. 控制台出现 SELECT id,name,author,press,status FROM book ORDER BY id ASC
  3. 按作者「余华」检索 → 2 本;名称「西」+ 作者「余华」→ 3 本
  4. 两个条件都空 → 10 本

第 6 步:借阅真正写库

前面全是「读」。这一步第一次 UPDATE 数据库,同时解决一个所有初学者都会踩的问题:改完数据,页面为什么不刷新?

这一步要做的事
  1. BookService.borrow(id):查出来 → 判状态 → 改 1 → 写回
  2. BookController 的 /book/find/{id}:调 borrow,返回 redirect:/book/list
  3. books.html:把 $.get(...) 改成整页跳转

① Service:先判再改

BookService.borrow() 填空版
public boolean borrow(Integer id) {
        // TODO:① Book book = getById(id); 查不到 return false
        // TODO:② 判状态:null 或 != 0 都 return false(已借出不能再借)
        // TODO:③ book.setStatus(1); return updateById(book);
    return false;
}
BookService.borrow() 答案
public boolean borrow(Integer id) {
    Book book = getById(id);
    if (book == null) {
        return false;                       // 书不存在
    }
    if (book.getStatus() == null || book.getStatus() != 0) {
        return false;                       // 1=借阅中 2=归还中,都不许再借
    }
    book.setStatus(1);
    return updateById(book);                // UPDATE book SET ... WHERE id = ?
}

为什么先 getById 再整行更新,而不是 new 一个只带 id 和 status 的对象?因为 MyBatis-Plus 只更新非 null 字段,只传两个字段在简单场景能跑通,但一旦需要状态校验、乐观锁就会出错 —— 先查、改、整行写回,逻辑最不容易错。

② Controller:为什么必须重定向

BookController /book/find/{id} 填空版
@RequestMapping("find/{id}")
public String findBook(@PathVariable("id") Integer id) {
    // TODO 5:boolean ok = bookService.borrow(id); 打印结果
    // TODO 6:返回 redirect:/book/list(不是 "books"!)
    return "books";
}
BookController /book/find/{id} 答案
@RequestMapping("find/{id}")
public String findBook(@PathVariable("id") Integer id) {
    boolean ok = bookService.borrow(id);
    System.out.println("申请借阅图书的id:" + id + " → "
            + (ok ? "借阅成功" : "借阅失败(已借出或不存在)"));
    return "redirect:/book/list";
}
为什么不能 return "books"?

因为 return "books" 是让 Thymeleaf 立刻用当前 Model 渲染这个模板 —— 而这时候 Model 里根本没有 books(这个方法一行 model.addAttribute 都没写)。结果页面是一张空表。

改成 redirect:/book/list 后,流程变成:

浏览器 GET /book/find/1 → 服务器 302 → 浏览器 GET /book/list → Controller 重新查库 → 渲染

这就是 PRG 模式(Post/Redirect/Get)。它还有个副作用好处:用户按 F5 刷新时,浏览器重发的是那个 GET,不会重复提交借阅。

③ 页面:为什么 $.get 不行

src/main/resources/templates/books.html 填空版
<script>
    function findBookById(id) {
        // TODO 7:改成 location.href = '/book/find/' + id;
        $.get("/book/find/" + id)
    }
</script>
src/main/resources/templates/books.html 答案
<script>
    function findBookById(id) {
        location.href = '/book/find/' + id;
    }
</script>

$.get 是在后台悄悄发一个请求,页面停在原地不动。数据库里状态已经改成「借阅中」了,但表格还显示「可借阅」—— 看起来就像功能坏了。整页跳转才能亲眼看到那一行从可借阅变成借阅中、按钮变灰。

④ 完整走一轮

// 借阅前 mysql> SELECT id,name,status FROM book WHERE id=1; 1 楚辞 0 // 点击「借阅」 GET /book/find/1 → HTTP 302 Location: /book/list 控制台:申请借阅图书的id:1 → 借阅成功 SQL:UPDATE book SET id=?,name=?,author=?,press=?,status=? WHERE id=? // 借阅后 mysql> SELECT id,name,status FROM book WHERE id=1; 1 楚辞 1 // 页面渲染 楚辞 | 屈原 | 中国文联出版社 | 借阅中 | [借阅](已置灰) // 再点一次 申请借阅图书的id:1 → 借阅失败(已借出或不存在) 数据库 status 仍是 1,不会重复借出
自检(完整闭环)
  1. 登录 → /book/list → 10 本书,「楚辞」是「可借阅」
  2. 点「借阅」→ 页面刷新,「楚辞」变「借阅中」、按钮置灰
  3. SELECT status FROM book WHERE id=1 → 1
  4. 重启应用再访问 → 仍然是「借阅中」(数据存在数据库里,不再随重启丢失)

附录:排错手册

这套流程里 90% 的报错都在这张表里。建议课前自己先踩一遍。

现象原因怎么办
Failed to determine a suitable driver classurl 或 driver-class-name 没填 / 填错检查第 1 步的 4 个配置,注意不是 spring.datasource.data-url
The server time zone value ... is unrecognizedurl 缺 serverTimezone加 serverTimezone=Asia/Shanghai
Public Key Retrieval is not allowedMySQL 8 要 SSL加 useSSL=false&allowPublicKeyRetrieval=true
Unknown database 'xxx'库没建 / 库名写错先跑 sql/book_db.sql
Access denied for user 'root'账号密码错先在命令行 mysql -u root -p 试一次
Table 'book_db.Book' doesn't exist漏了 @TableName("book")类名 Book ≠ 表名 book,必须显式写
No constructor found ... Book实体没有无参构造器加 public Book() {}
No qualifying bean of type '...BookMapper'漏了 @MapperScan启动类加注解,或接口加 @Mapper
页面一行数据都没有,但也不报错① Model 键名不是 books
② status 还是 String,跟 '0' 比较
两个都不报错,只能靠对照代码查
检索结果比预期少(AND 而不是 OR)两个 like 之间没写 .or()看控制台 SQL 里有没有 OR
点借阅后页面空白return "books" 但 Model 里没数据改成 redirect:/book/list
点借阅后页面没反应用了 $.get改成 location.href
访问 /book/list 跳回登录页没登录,被 LoginFilter 拦了先 /dologin,或用 F12 看是不是 302
控制台看不到任何 SQLlog-impl 没配加 mybatis-plus.configuration.log-impl=...StdOutImpl
一条通用排查顺序

① 库连上了吗(第 1 步)→ ② 表名对上了吗(第 2 步)→ ③ Mapper 注册了吗(第 3 步)→ ④ SQL 长什么样(看控制台)→ ⑤ Model 里有没有数据 → ⑥ 页面拿到没有

键盘 ← → 也能翻;右上角可切换「填空版 / 答案版」