Spring Boot 4 + Thymeleaf 电商主题实战 阶段四:数据访问与业务功能开发
Spring Boot 4 + Thymeleaf 电商主题实战 阶段四:数据访问与业务功能开发
学习目标: 将 Thymeleaf 与数据库结合,完成常见业务功能。
第15章 · MyBatis-Plus 整合数据库
章节目标
- 掌握 MyBatis-Plus 的基本用法:实体映射、Mapper 继承、通用 CRUD
- 理解 Thymeleaf 服务端渲染模式:Controller 返回视图名,Model 传递数据
- 学会使用 LambdaQueryWrapper 构建类型安全的查询条件
- 熟悉 Spring Boot 的多模块项目结构和 pom.xml 继承机制
- 能够运行完整的 Spring Boot + MyBatis-Plus + Thymeleaf 应用
理论知识
MyBatis-Plus 是 MyBatis 的增强工具,在保留 MyBatis 所有特性的基础上,提供了代码生成器、通用 CRUD、分页插件等功能。本章重点介绍基础使用:实体类 通过 @TableName、@TableId、@TableField 注解与数据库表、主键、列进行映射;Mapper 接口 继承 BaseMapper<T> 即可获得全套增删改查方法,无需写一行 SQL;Service 层 通过注入 Mapper 实现业务逻辑封装。
Thymeleaf 是服务端模板引擎。Controller 返回字符串"视图名",Spring Boot 自动在 templates/ 目录下查找对应的 .html 文件,并通过 Model 将数据传递到模板。模板中使用 th:text、th:each、th:if 等属性表达式渲染动态内容。这种模式的优点是 SEO 友好、初始加载快;缺点是服务端压力大,不适合实时性要求高的场景。
项目结构
chapter15-db/
├── pom.xml # Maven 配置(继承父 pom)
└── src/main/
├── java/com/lihaozhe/ch15/
│ ├── Ch15Application.java # 启动类
│ ├── controller/
│ │ └── ProductController.java # 商品控制器(@Controller)
│ ├── dao/
│ │ └── ProductMapper.java # Mapper 接口(继承 BaseMapper)
│ ├── model/
│ │ └── Product.java # 商品实体(MP 注解)
│ └── service/
│ └── ProductService.java # 商品服务(CRUD 封装)
└── resources/
├── application.yml # 应用配置(端口、数据源、MP)
├── schema.sql # 建表脚本(启动时执行)
├── data.sql # 种子数据(启动时插入)
└── templates/
├── product-list.html # 商品列表页
└── product-detail.html # 商品详情页
完整代码
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
第15章:MyBatis-Plus 整合数据库
本章依赖:
- spring-boot-starter-webmvc:Web 启动器
- spring-boot-starter-thymeleaf:模板引擎
- mybatis-plus-spring-boot4-starter:MP 官方 Boot 4 专用 starter
- mybatis-plus-jsqlparser:分页插件(selectPage 必须)
- mysql-connector-j:MySQL 驱动(运行时依赖)
-->
<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>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-thymeleaf</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter15-db</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- MyBatis-Plus:增强 CRUD,无需写 SQL -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
</dependency>
<!-- 分页插件:selectPage 真实分页必须引入 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Ch15Application.java
package com.lihaozhe.ch15;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第15章 启动入口。
*
* <p>理论知识:{@code @SpringBootApplication} 组合注解包含:
* {@code @SpringBootConfiguration}(标记配置类)、
* {@code @EnableAutoConfiguration}(自动装配 Bean)、
* {@code @ComponentScan}(扫描组件)。</p>
*/
@SpringBootApplication
public class Ch15Application {
/**
* 程序入口:SpringApplication.run 启动内嵌 Tomcat + 初始化 Spring 容器。
*
* @param args 命令行参数(本教程不接收参数)
*/
public static void main(String[] args) {
SpringApplication.run(Ch15Application.class, args);
}
}
ProductController.java
package com.lihaozhe.ch15.controller;
import com.lihaozhe.ch15.model.Product;
import com.lihaozhe.ch15.service.ProductService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/**
* 商品控制器(第15章:服务端渲染 Thymeleaf 模板)
*
* <p>理论知识 —— @Controller + 返回字符串 = 视图名:
* 返回 "product-list" 表示渲染 templates/product-list.html,
* Model 中的数据通过 th:text / th:each 等在页面中展示。</p>
*/
@Controller
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
/**
* 商品列表页。
*
* @param category 分类筛选(可选)
* @param model 传递数据到视图
* @return 视图名 "product-list"
*/
@GetMapping("/")
public String list(@RequestParam(required = false) String category, Model model) {
List<Product> products = productService.findByCategory(category);
model.addAttribute("products", products);
model.addAttribute("category", category);
return "product-list";
}
/**
* 商品详情页。
*
* @param id 商品ID
* @param model 传递数据到视图
* @return 视图名 "product-detail"
*/
@GetMapping("/product/{id}")
public String detail(@PathVariable Long id, Model model) {
Product product = productService.findById(id);
model.addAttribute("product", product);
return "product-detail";
}
}
ProductMapper.java
package com.lihaozhe.ch15.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.lihaozhe.ch15.model.Product;
import org.apache.ibatis.annotations.Mapper;
/**
* 商品 Mapper(第15章:继承 BaseMapper 即得全套 CRUD)
*
* <p>理论知识 —— BaseMapper<T> 提供了哪些方法?(不用写一行 SQL)
* <pre>
* 插入:insert(T)
* 删除:deleteById / deleteBatchIds / delete(Wrapper) / deleteByMap
* 更新:updateById(T) (按非 null 字段更新)
* 查询:selectById / selectBatchIds / selectList(Wrapper)
* selectOne / selectCount / selectPage ...
* </pre>
*/
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
// 一个方法都不用写!通用 CRUD 全部来自 BaseMapper
}
Product.java
package com.lihaozhe.ch15.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品实体(第15章:MyBatis-Plus 注解版)
*
* <p>理论知识 —— MP 的核心注解:
* <ul>
* <li>@TableName("表名"):类 → 表的映射;</li>
* <li>@TableId(type = IdType.AUTO):主键 + 自增策略;</li>
* <li>@TableField("列名"):字段 ↔ 列名的映射(驼峰自动转换可省略)。</li>
* </ul>
*/
@TableName("t_ch15_product")
public class Product {
/**
* 主键。IdType.AUTO = 数据库自增,插入后 MP 自动回填 id。
*/
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private BigDecimal price;
private String category;
private String description;
private Integer stock;
private LocalDateTime createTime;
private LocalDateTime updateTime;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
public LocalDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
public LocalDateTime getUpdateTime() {
return updateTime;
}
public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
}
ProductService.java
package com.lihaozhe.ch15.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.lihaozhe.ch15.dao.ProductMapper;
import com.lihaozhe.ch15.model.Product;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 商品服务(第15章:简单 CRUD 封装)
*
* <p>理论知识 —— Service 层职责:
* 隔离 Controller 与 Mapper,集中业务逻辑(如价格校验、库存扣减规则),
* 方便单元测试与复用。MP 的 Mapper 可直接注入使用。</p>
*/
@Service
public class ProductService {
private final ProductMapper productMapper;
public ProductService(ProductMapper productMapper) {
this.productMapper = productMapper;
}
/**
* 查询全部商品。
*
* @return 商品列表
*/
public List<Product> findAll() {
return productMapper.selectList(null);
}
/**
* 根据 id 查商品。
*
* @param id 商品ID
* @return 商品实体(不存在返回 null)
*/
public Product findById(Long id) {
return productMapper.selectById(id);
}
/**
* 根据分类筛选商品。
*
* @param category 分类名称(可选)
* @return 商品列表
*/
public List<Product> findByCategory(String category) {
if (category == null || category.isEmpty()) {
return findAll();
}
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Product::getCategory, category)
.orderByAsc(Product::getPrice);
return productMapper.selectList(wrapper);
}
}
application.yml
# 第15章:MyBatis-Plus 整合数据库
# 端口规则:8080 + 章号 15 → 8115
server:
port: 8115
spring:
application:
name: chapter15-db
# 数据源配置
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_thymeleaf?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 5
minimum-idle: 2
connection-timeout: 10000
# 启动时自动执行 schema.sql / data.sql
sql:
init:
mode: always
thymeleaf:
cache: false
encoding: UTF-8
mode: HTML
check-template: true
check-template-location: true
mybatis-plus:
configuration:
# 驼峰映射(MP 默认开,显式写出便于理解)
map-underscore-to-camel-case: true
# SQL 日志(开发期开启)
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
schema.sql
-- 第15章:商品表
-- 使用 CREATE TABLE IF NOT EXISTS 防止重复执行报错
CREATE TABLE IF NOT EXISTS t_ch15_product (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '商品ID',
name VARCHAR(100) NOT NULL COMMENT '商品名称',
price DECIMAL(10,2) NOT NULL COMMENT '商品价格',
category VARCHAR(50) COMMENT '商品分类',
description VARCHAR(500) COMMENT '商品描述',
stock INT DEFAULT 0 COMMENT '库存',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第15章商品表';
data.sql
-- 第15章:商品种子数据
INSERT INTO t_ch15_product (name, price, category, description, stock) VALUES
('iPhone 16 Pro', 8999.00, '手机', '苹果最新旗舰手机,钛金属边框,A18 Pro 芯片', 100),
('Samsung Galaxy S24', 6999.00, '手机', '三星旗舰,AI 摄影,200MP 摄像头', 80),
('MacBook Air M3', 9999.00, '电脑', '苹果轻薄笔记本,M3 芯片,18小时续航', 50),
('Dell XPS 13', 7999.00, '电脑', '戴尔轻薄本,英特尔酷睿 Ultra 处理器', 60),
('iPad Pro 11', 6999.00, '平板', '苹果平板电脑,M4 芯片,OLED 屏幕', 70),
('Sony WH-1000XM5', 2499.00, '耳机', '索尼降噪耳机,30小时续航', 120),
('Nintendo Switch OLED', 2399.00, '游戏机', '任天堂游戏机,7寸 OLED 屏幕', 90),
('PlayStation 5', 4299.00, '游戏机', '索尼最新游戏机,4K 游戏体验', 40),
('AirPods Pro 2', 1899.00, '耳机', '苹果无线耳机,主动降噪', 150),
('Xiaomi 14 Pro', 4999.00, '手机', '小米旗舰,徕卡影像系统', 110),
('Huawei Mate 60 Pro', 6999.00, '手机', '华为旗舰,卫星通信', 75),
('LG C3 OLED TV', 12999.00, '电视', 'LG OLED 电视,完美黑色表现', 30);
templates/product-list.html
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>第15章 · 商品列表</title>
<!-- ===== 前端框架(全部走 CDN,免构建)===== -->
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.9/dist/axios.min.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 顶部导航 -->
<nav class="navbar navbar-dark bg-dark px-4">
<span class="navbar-brand">第15章 · MyBatis-Plus 商品商城</span>
</nav>
<div class="container mt-4">
<!-- 分类筛选按钮 -->
<div class="mb-3">
<a th:href="@{/}" class="btn btn-outline-secondary btn-sm me-2">全部</a>
<a th:href="@{/(category='手机')}" class="btn btn-outline-primary btn-sm me-2">手机</a>
<a th:href="@{/(category='电脑')}" class="btn btn-outline-primary btn-sm me-2">电脑</a>
<a th:href="@{/(category='平板')}" class="btn btn-outline-primary btn-sm me-2">平板</a>
<a th:href="@{/(category='耳机')}" class="btn btn-outline-primary btn-sm me-2">耳机</a>
<a th:href="@{/(category='游戏机')}" class="btn btn-outline-primary btn-sm me-2">游戏机</a>
<a th:href="@{/(category='电视')}" class="btn btn-outline-primary btn-sm me-2">电视</a>
</div>
<!-- 商品网格 -->
<div class="row row-cols-1 row-cols-md-3 g-4">
<!--
th:each 遍历 products 列表,每次迭代变量名为 product,下标为 iterStat
${product} 是表达式,读取当前迭代对象
-->
<div class="col" th:each="product, iterStat : ${products}">
<!-- 卡片:展示商品信息 -->
<div class="card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<!-- th:text 输出商品名,HTML 转义防 XSS -->
<h5 class="card-title" th:text="${product.name}">商品名</h5>
<!-- th:text 输出分类,作为徽章 -->
<span class="badge bg-info mb-2" th:text="${product.category}">分类</span>
<!-- 价格格式化(BigDecimal 自动转字符串) -->
<p class="card-text text-danger fw-bold" th:text="'¥' + ${product.price}">价格</p>
<!-- 库存展示 -->
<p class="card-text text-muted small" th:text="'库存:' + ${product.stock}">库存</p>
<!-- 描述截断 -->
<p class="card-text text-muted flex-grow-1" th:text="${#strings.abbreviate(product.description, 50)}">描述</p>
<!-- 详情链接:th:href 拼接 URL,@{} 是 Thymeleaf 链接表达式 -->
<a th:href="@{/product/{id}(id=${product.id})}" class="btn btn-primary mt-auto">查看详情</a>
</div>
</div>
</div>
</div>
<!-- 空数据提示 -->
<div th:if="${#lists.isEmpty(products)}" class="alert alert-warning mt-4">
暂无商品数据
</div>
</div>
</body>
</html>
templates/product-detail.html
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>第15章 · 商品详情</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.9/dist/axios.min.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<nav class="navbar navbar-dark bg-dark px-4">
<span class="navbar-brand">第15章 · 商品详情</span>
<a th:href="@{/}" class="btn btn-outline-light btn-sm">返回列表</a>
</nav>
<div class="container mt-4" th:if="${product != null}">
<div class="row">
<!-- 商品信息卡片 -->
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-body">
<h2 class="card-title" th:text="${product.name}">商品名称</h2>
<span class="badge bg-info mb-3" th:text="${product.category}">分类</span>
<p class="text-danger fw-bold fs-3" th:text="'¥' + ${product.price}">价格</p>
<p class="text-muted" th:text="'库存:' + ${product.stock} + ' 件'">库存</p>
<hr>
<h5>商品描述</h5>
<p class="text-muted" th:text="${product.description}">描述内容</p>
<hr>
<p class="small text-muted">
创建时间:<span th:text="${product.createTime}">创建时间</span><br>
更新时间:<span th:text="${product.updateTime}">更新时间</span>
</p>
</div>
</div>
</div>
<!-- 侧边栏:操作按钮 -->
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title">操作</h5>
<button class="btn btn-success w-100 mb-2">加入购物车</button>
<button class="btn btn-outline-primary w-100 mb-2">立即购买</button>
<button class="btn btn-outline-secondary w-100">收藏</button>
</div>
</div>
</div>
</div>
</div>
<!-- 商品不存在提示 -->
<div class="container mt-4" th:if="${product == null}">
<div class="alert alert-danger">
商品不存在或已下架
</div>
<a th:href="@{/}" class="btn btn-primary">返回列表</a>
</div>
</body>
</html>
运行验证
cd sb-thymeleaf/chapter15-db
mvn clean package -DskipTests
java -jar target/chapter15-db-1.0.0.jar
# 浏览器访问 http://localhost:8115/
# 商品列表页:http://localhost:8115/
# 商品详情页:http://localhost:8115/product/1
注意:
- 启动时会自动执行
schema.sql创建表t_ch15_product - 执行
data.sql插入 12 条商品种子数据 - 数据库连接:MySQL 118.31.221.165:3306,库名
sb_thymeleaf,用户 root,密码 lihaozhe
页面效果
以下截图均为本地启动后浏览器真实渲染结果。


常见坑
-
MyBatis-Plus 依赖缺失:如果只引入
mybatis-plus-spring-boot4-starter而没有分页插件,会在调用selectPage时报错。本章虽然没用到分页,但建议引入mybatis-plus-jsqlparser以备不时之需。 -
驼峰映射:
@TableField注解在属性名与列名一一对应(驼峰转下划线)时可以省略;如果列名不遵循驼峰规则,必须显式指定。 -
SQL 日志输出:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl会打印所有 SQL,开发期方便调试,但生产环境应关闭(仅保留错误日志)。
第16章 · 多表关联与分页
章节目标
- 理解 MyBatis-Plus 分页插件原理与配置方法
- 掌握多表关联处理的两种思路:内存关联 vs JOIN 查询
- 学会使用
Page<T>对象进行分页查询 - 熟悉 Thymeleaf 中分页导航的实现(页码、上一页、下一页)
- 完成商品列表的分页展示和分类筛选
理论知识
分页插件原理:
MyBatis-Plus 的分页通过"内部拦截器"实现。
核心是 PaginationInnerInterceptor,它会拦截 selectPage 等分页查询,自动翻译为 LIMIT offset, size 和 COUNT(*) 两条 SQL。
如果不注册此拦截器,分页会失效(查全表,伪分页)。
多表关联方法:
- 内存关联(本章采用):分别查两张表,然后在 Java 内存中用 Map 组装。优点:简单;缺点:N+1 查询、数据量大时性能差。
- JOIN 查询:自定义 SQL 写 JOIN。优点:一次查询;缺点:复杂。
- 冗余字段:在商品表中冗余分类名。优点:查询简单;缺点:数据一致性维护。
本章采用"内存关联"方式:先分页查商品,再根据分类 ID 查分类名填充到商品对象中。
项目结构
chapter16-page/
├── pom.xml
└── src/main/
├── java/com/lihaozhe/ch16/
│ ├── Ch16Application.java
│ ├── config/
│ │ └── MybatisPlusConfig.java # 分页插件配置
│ ├── controller/
│ │ └── ProductController.java
│ ├── dao/
│ │ ├── CategoryMapper.java
│ │ └── ProductMapper.java
│ ├── model/
│ │ ├── Category.java
│ │ └── Product.java # 含 @TableField(exist=false) 冗余字段
│ └── service/
│ └── ProductService.java
└── resources/
├── application.yml
├── schema.sql # 创建分类表和商品表
├── data.sql
└── templates/
└── product-list.html # 带分页导航
完整代码
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<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>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-thymeleaf</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter16-page</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Ch16Application.java
package com.lihaozhe.ch16;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第16章 启动入口。
*
* <p>理论知识:{@code @SpringBootApplication} 组合注解包含三个核心注解,
* 启用自动配置与组件扫描,初始化 Spring 容器。</p>
*/
@SpringBootApplication
public class Ch16Application {
/**
* 程序入口:SpringApplication.run 启动内嵌 Tomcat + 初始化 Spring 容器。
*
* @param args 命令行参数(本教程不接收参数)
*/
public static void main(String[] args) {
SpringApplication.run(Ch16Application.class, args);
}
}
config/MybatisPlusConfig.java
package com.lihaozhe.ch16.config;
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 配置类(第16章:启用分页插件)
*
* <p>理论知识 —— 分页插件原理:
* MP 的分页通过"内部拦截器"实现。注册 PaginationInnerInterceptor 后,
* selectPage 会自动翻译成 LIMIT offset, size + COUNT 查询,
* 不加这个 Bean,分页会失效(查全表,伪分页)。</p>
*/
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页拦截器:自动处理 LIMIT 和 COUNT
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}
controller/ProductController.java
package com.lihaozhe.ch16.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.lihaozhe.ch16.model.Category;
import com.lihaozhe.ch16.model.Product;
import com.lihaozhe.ch16.service.ProductService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/**
* 商品控制器(第16章:分页展示)
*
* <p>理论知识 —— 分页展示的三个要素:
* 1. 当前页数据(Page.records);
* 2. 总记录数(Page.total);
* 3. 分页导航(页码、上一页、下一页链接)。</p>
*/
@Controller
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
/**
* 商品列表页(分页)。
*
* @param pageNum 当前页码(默认1)
* @param pageSize 每页大小(默认6)
* @param categoryId 分类筛选(可选)
* @param model 传递数据到视图
* @return 视图名 "product-list"
*/
@GetMapping("/")
public String list(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "6") int pageSize,
@RequestParam(required = false) Long categoryId,
Model model) {
Page<Product> page = productService.findPage(pageNum, pageSize, categoryId);
List<Category> categories = productService.findAllCategories();
model.addAttribute("page", page);
model.addAttribute("categories", categories);
model.addAttribute("currentCategoryId", categoryId);
return "product-list";
}
}
dao/CategoryMapper.java
package com.lihaozhe.ch16.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.lihaozhe.ch16.model.Category;
import org.apache.ibatis.annotations.Mapper;
/**
* 分类 Mapper(第16章:基础 CRUD)
*/
@Mapper
public interface CategoryMapper extends BaseMapper<Category> {
// 通用 CRUD 来自 BaseMapper
}
dao/ProductMapper.java
package com.lihaozhe.ch16.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.lihaozhe.ch16.model.Product;
import org.apache.ibatis.annotations.Mapper;
/**
* 商品 Mapper(第16章:基础 CRUD)
*/
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
// 通用 CRUD 来自 BaseMapper
}
model/Category.java
package com.lihaozhe.ch16.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
/**
* 分类实体(第16章:多表关联示例)
*
* <p>理论知识 —— 一对多关系:
* 一个分类下可以有多个商品。在关系型数据库中,
* 通过外键(category_id)在商品表中引用分类表的主键,
* 实现数据关联。</p>
*/
@TableName("t_ch16_category")
public class Category {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private String description;
private Integer sortOrder;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
}
model/Product.java
package com.lihaozhe.ch16.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品实体(第16章:外键关联分类表)
*
* <p>理论知识 —— @TableField 映射非驼峰列名:
* categoryId 属性对应数据库列 category_id,
* MP 会自动将驼峰转为下划线,无需显式指定 @TableField。
* 但如果列名完全不按驼峰规则,需要 @TableField("列名") 标注。</p>
*/
@TableName("t_ch16_product")
public class Product {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private BigDecimal price;
/**
* 外键:关联 t_ch16_category 表的 id
*/
private Long categoryId;
/**
* 非数据库字段:通过 JOIN 或查询填充的冗余信息,
* 用 @TableField(exist = false) 标记,避免 MP 尝试映射该列。
*/
@TableField(exist = false)
private String categoryName;
private String description;
private Integer stock;
private LocalDateTime createTime;
private LocalDateTime updateTime;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public Long getCategoryId() {
return categoryId;
}
public void setCategoryId(Long categoryId) {
this.categoryId = categoryId;
}
public String getCategoryName() {
return categoryName;
}
public void setCategoryName(String categoryName) {
this.categoryName = categoryName;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
public LocalDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
public LocalDateTime getUpdateTime() {
return updateTime;
}
public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
}
service/ProductService.java
package com.lihaozhe.ch16.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.lihaozhe.ch16.dao.CategoryMapper;
import com.lihaozhe.ch16.dao.ProductMapper;
import com.lihaozhe.ch16.model.Category;
import com.lihaozhe.ch16.model.Product;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 商品服务(第16章:多表 + 分页)
*
* <p>理论知识 —— 多表关联的常见方法:
* 1. 单表查商品 + 单表查分类,然后在内存中组装(本章采用);
* 2. 自定义 SQL 写 JOIN 查询(复杂场景);
* 3. 使用 @TableField 冗余字段(简单冗余)。</p>
*/
@Service
public class ProductService {
private final ProductMapper productMapper;
private final CategoryMapper categoryMapper;
public ProductService(ProductMapper productMapper, CategoryMapper categoryMapper) {
this.productMapper = productMapper;
this.categoryMapper = categoryMapper;
}
/**
* 分页查询商品(带分类名填充)。
*
* @param pageNum 页码(从1开始)
* @param pageSize 每页大小
* @param categoryId 分类筛选(可选)
* @return 分页结果(含总记录数、总页数、当前页数据)
*/
public Page<Product> findPage(int pageNum, int pageSize, Long categoryId) {
// 1. 创建分页对象:会自动执行 COUNT(*) 和 LIMIT 查询
Page<Product> page = new Page<>(pageNum, pageSize);
// 2. 条件构造:按分类筛选
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
if (categoryId != null && categoryId > 0) {
wrapper.eq(Product::getCategoryId, categoryId);
}
wrapper.orderByAsc(Product::getPrice);
// 3. 执行分页查询
Page<Product> resultPage = productMapper.selectPage(page, wrapper);
// 4. 填充分类名(内存关联,避免 JOIN 复杂度)
if (!resultPage.getRecords().isEmpty()) {
List<Category> categories = categoryMapper.selectList(null);
Map<Long, String> categoryMap = categories.stream()
.collect(Collectors.toMap(Category::getId, Category::getName));
resultPage.getRecords().forEach(p ->
p.setCategoryName(categoryMap.get(p.getCategoryId())));
}
return resultPage;
}
/**
* 查询全部分类(用于下拉/筛选)。
*
* @return 分类列表
*/
public List<Category> findAllCategories() {
LambdaQueryWrapper<Category> wrapper = new LambdaQueryWrapper<>();
wrapper.orderByAsc(Category::getSortOrder);
return categoryMapper.selectList(wrapper);
}
}
application.yml
# 第16章:多表 + 分页
# 端口规则:8080 + 章号 16 → 8116
server:
port: 8116
spring:
application:
name: chapter16-page
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_thymeleaf?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 5
minimum-idle: 2
connection-timeout: 10000
sql:
init:
mode: always
thymeleaf:
cache: false
encoding: UTF-8
mode: HTML
check-template: true
check-template-location: true
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
schema.sql
-- 第16章:分类表
CREATE TABLE IF NOT EXISTS t_ch16_category (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分类ID',
name VARCHAR(50) NOT NULL COMMENT '分类名称',
description VARCHAR(200) COMMENT '分类描述',
sort_order INT DEFAULT 0 COMMENT '排序号'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第16章分类表';
-- 第16章:商品表(外键关联分类)
CREATE TABLE IF NOT EXISTS t_ch16_product (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '商品ID',
name VARCHAR(100) NOT NULL COMMENT '商品名称',
price DECIMAL(10,2) NOT NULL COMMENT '商品价格',
category_id BIGINT COMMENT '分类ID(外键)',
description VARCHAR(500) COMMENT '商品描述',
stock INT DEFAULT 0 COMMENT '库存',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
FOREIGN KEY (category_id) REFERENCES t_ch16_category(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第16章商品表';
data.sql
-- 第16章:分类种子数据
INSERT INTO t_ch16_category (name, description, sort_order) VALUES
('手机数码', '智能手机、平板、耳机等移动设备', 1),
('电脑办公', '笔记本、台式机、外设等', 2),
('游戏娱乐', '游戏机、游戏配件等', 3),
('智能家居', '智能音箱、摄像头等', 4);
-- 第16章:商品种子数据(关联分类)
INSERT INTO t_ch16_product (name, price, category_id, description, stock) VALUES
('iPhone 16 Pro', 8999.00, 1, '苹果最新旗舰手机,钛金属边框', 100),
('Samsung Galaxy S24', 6999.00, 1, '三星旗舰,AI 摄影', 80),
('Xiaomi 14 Pro', 4999.00, 1, '小米旗舰,徕卡影像', 110),
('Huawei Mate 60 Pro', 6999.00, 1, '华为旗舰,卫星通信', 75),
('MacBook Air M3', 9999.00, 2, '苹果轻薄笔记本', 50),
('Dell XPS 13', 7999.00, 2, '戴尔轻薄本', 60),
('iPad Pro 11', 6999.00, 1, '苹果平板电脑', 70),
('Sony WH-1000XM5', 2499.00, 1, '索尼降噪耳机', 120),
('Nintendo Switch OLED', 2399.00, 3, '任天堂游戏机', 90),
('PlayStation 5', 4299.00, 3, '索尼游戏机', 40),
('AirPods Pro 2', 1899.00, 1, '苹果无线耳机', 150),
('LG C3 OLED TV', 12999.00, 2, 'LG OLED 电视', 30),
('Echo Dot', 399.00, 4, '亚马逊智能音箱', 200),
('Mi Smart Camera', 299.00, 4, '小米智能摄像头', 180),
('Roomba i7', 2999.00, 4, 'iRobot 扫地机器人', 60);
templates/product-list.html
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>第16章 · 商品列表(分页)</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.9/dist/axios.min.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<nav class="navbar navbar-dark bg-dark px-4">
<span class="navbar-brand">第16章 · 多表关联 + 分页商品商城</span>
</nav>
<div class="container mt-4">
<!-- 分类筛选 -->
<div class="mb-3">
<a th:href="@{/}" class="btn btn-outline-secondary btn-sm me-2">全部</a>
<!--
th:each 遍历 categories,每次迭代变量为 category
@{/?(categoryId=${category.id})} 是链接表达式,生成 /?categoryId=1 这样的 URL
-->
<a th:each="category : ${categories}"
th:href="@{/(categoryId=${category.id})}"
th:text="${category.name}"
th:class="'btn btn-sm me-2 ' + (${currentCategoryId != null && currentCategoryId == category.id} ? 'btn-primary' : 'btn-outline-primary')">分类</a>
</div>
<!-- 商品网格 -->
<div class="row row-cols-1 row-cols-md-3 g-4">
<!--
th:each 遍历 page.records(当前页商品列表)
${product} 是当前迭代对象
-->
<div class="col" th:each="product : ${page.records}">
<div class="card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<h5 class="card-title" th:text="${product.name}">商品名</h5>
<!-- 展示分类名(从内存关联的冗余字段) -->
<span class="badge bg-info mb-2" th:text="${product.categoryName}">分类</span>
<p class="card-text text-danger fw-bold" th:text="'¥' + ${product.price}">价格</p>
<p class="card-text text-muted small" th:text="'库存:' + ${product.stock}">库存</p>
<p class="card-text text-muted flex-grow-1" th:text="${#strings.abbreviate(product.description, 50)}">描述</p>
</div>
</div>
</div>
</div>
<!-- 分页导航 -->
<nav class="mt-4">
<ul class="pagination justify-content-center">
<!-- 上一页:如果当前页 > 1 才显示 -->
<li class="page-item" th:if="${page.current > 1}">
<a class="page-link" th:href="@{/(pageNum=${page.current - 1}, pageSize=${page.size}, categoryId=${currentCategoryId})}">上一页</a>
</li>
<!--
页码循环:th:each 遍历 #numbers.sequence(1, page.pages) 生成 1..总页数
${i} 是当前页码,条件判断高亮当前页
-->
<li class="page-item"
th:each="i : ${#numbers.sequence(1, page.pages)}"
th:class="${i == page.current} ? 'active' : ''">
<a class="page-link" th:href="@{/(pageNum=${i}, pageSize=${page.size}, categoryId=${currentCategoryId})}" th:text="${i}">1</a>
</li>
<!-- 下一页:如果当前页 < 总页数 才显示 -->
<li class="page-item" th:if="${page.current < page.pages}">
<a class="page-link" th:href="@{/(pageNum=${page.current + 1}, pageSize=${page.size}, categoryId=${currentCategoryId})}">下一页</a>
</li>
</ul>
</nav>
<!-- 分页信息 -->
<div class="text-center text-muted">
第 <span th:text="${page.current}">1</span> 页 / 共
<span th:text="${page.pages}">1</span> 页
(总 <span th:text="${page.total}">0</span> 条记录)
</div>
</div>
</body>
</html>
运行验证
cd sb-thymeleaf/chapter16-page
mvn clean package -DskipTests
java -jar target/chapter16-page-1.0.0.jar
# 浏览器访问 http://localhost:8116/
# 默认显示第1页(每页6条)
# 点击分类筛选:http://localhost:8116/?categoryId=1
# 翻页:http://localhost:8116/?pageNum=2&pageSize=6
注意:
- 表
t_ch16_category和t_ch16_product会在启动时自动创建 - 数据初始化:4 个分类 + 15 个商品
- 分页插件
PaginationInnerInterceptor必须注册,否则不会分页
页面效果
以下截图均为本地启动后浏览器真实渲染结果。


常见坑
-
忘记注册分页拦截器:如果没有在
MybatisPlusConfig中注册PaginationInnerInterceptor,调用selectPage会查全表,返回伪分页结果(total 和当前页数据都不对)。 -
@TableField(exist = false) 缺失:如果商品实体中有"冗余字段"(如 categoryName),且数据库表没有对应列,必须用
@TableField(exist = false)标记,否则 MP 会尝试映射该列,导致 SQL 错误。 -
分类关联时机:本章在内存中关联分类名,适合数据量小的情况。如果商品表有百万级数据,应该考虑 JOIN 查询或索引优化。
-
分页参数顺序:
Page(pageNum, pageSize)中 pageNum 是 1 开始 的页码,不是 0 开始的偏移量。MyBatis-Plus 会自动计算offset = (pageNum - 1) * pageSize。
第17章 · HttpSession 购物车
章节目标
- 理解 HttpSession 的工作原理和生命周期
- 掌握基于 Session 的购物车实现
- 学会使用
Map<Long, CartItem>存储购物车项 - 熟悉购物车的核心操作:加购、修改数量、删除、清空
- 完成完整的购物车流程(列表 → 加购 → 查看 → 修改)
理论知识
HttpSession 原理:
- 每个浏览器第一次访问服务器时,服务器为其创建唯一的 Session ID
- Session ID 通过 Cookie 返回给浏览器,后续请求浏览器自动携带
- 服务器用 Session ID 找到对应的 Session 对象(存储用户数据的容器)
- 购物车数据就存储在 Session 中(如
session.setAttribute("cart", cartMap))
优点:实现简单,数据在服务端安全,适合学习原理。
缺点:每个用户一个 Session,服务器内存占用大;分布式环境下需要 Session 共享方案(如 Redis)。
购物车数据模型:
- 购物车本质是一个
Map<Long, CartItem> - key = 商品 ID,value = 购物车项(商品名、单价、数量、小计)
- 加购:如果商品已存在,增加数量;否则新增
- 修改:更新数量(≤0 则删除)
- 删除:从 Map 移除
- 清空:移除 Session 的 “cart” 属性
项目结构
chapter17-cart/
├── pom.xml
└── src/main/
├── java/com/lihaozhe/ch17/
│ ├── Ch17Application.java
│ ├── controller/
│ │ └── CartController.java # 购物车核心逻辑
│ ├── dao/
│ │ └── ProductMapper.java
│ ├── model/
│ │ ├── CartItem.java # 购物车项
│ │ └── Product.java
│ └── service/
│ └── ProductService.java
└── resources/
├── application.yml
├── schema.sql
├── data.sql
└── templates/
├── product-list.html
└── cart.html # 购物车页面
完整代码
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<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>
<parent>
<groupId>com.lihaozhe</groupId>
<artifactId>sb-thymeleaf</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>chapter17-cart</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Ch17Application.java
package com.lihaozhe.ch17;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 第17章 启动入口。
*/
@SpringBootApplication
public class Ch17Application {
public static void main(String[] args) {
SpringApplication.run(Ch17Application.class, args);
}
}
controller/CartController.java
package com.lihaozhe.ch17.controller;
import com.lihaozhe.ch17.model.CartItem;
import com.lihaozhe.ch17.model.Product;
import com.lihaozhe.ch17.service.ProductService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import jakarta.servlet.http.HttpSession;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 购物车控制器(第17章:HttpSession 临时存储)
*
* <p>理论知识 —— HttpSession 购物车:
* HttpSession 是服务端为每个浏览器会话分配的存储空间。
* 每个用户访问时,服务器为其创建一个 Session,ID 通过 Cookie 返回给浏览器。
* 之后该浏览器的每次请求都会携带 SessionID,服务器据此找到对应的 Session,
* 从而识别"这是同一个用户"。购物车数据就存在 Session 中。</p>
*
* <p>优点:实现简单,数据在服务端,安全性高。
* 缺点:服务器内存占用大(每个用户一个 Session),水平扩展困难
* (需要 Session 共享方案如 Redis)。适合学习原理。</p>
*/
@Controller
public class CartController {
private final ProductService productService;
public CartController(ProductService productService) {
this.productService = productService;
}
/**
* 商品列表页。
*/
@GetMapping("/")
public String list(Model model) {
List<Product> products = productService.findAll();
model.addAttribute("products", products);
return "product-list";
}
/**
* 购物车页。
*
* <p>从 Session 中获取购物车 Map,传递给视图渲染。</p>
*/
@GetMapping("/cart")
public String cart(HttpSession session, Model model) {
Map<Long, CartItem> cart = getCart(session);
List<CartItem> items = new ArrayList<>(cart.values());
// 计算总数量和总金额
int totalCount = items.stream().mapToInt(CartItem::getQuantity).sum();
BigDecimal totalAmount = items.stream()
.map(CartItem::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
model.addAttribute("cartItems", items);
model.addAttribute("totalCount", totalCount);
model.addAttribute("totalAmount", totalAmount);
return "cart";
}
/**
* 加购:把商品加入 Session 中的购物车。
*
* @param productId 商品ID
* @param quantity 数量(默认1)
*/
@PostMapping("/cart/add")
public String addToCart(@RequestParam Long productId,
@RequestParam(defaultValue = "1") int quantity,
HttpSession session) {
// 1. 从 Session 获取购物车(不存在则创建新 Map)
Map<Long, CartItem> cart = getCart(session);
// 2. 查询商品信息(用于生成 CartItem)
Product product = productService.findById(productId);
if (product == null) {
return "redirect:/cart";
}
// 3. 如果购物车已有该商品,增加数量;否则新增
CartItem existing = cart.get(productId);
if (existing != null) {
existing.setQuantity(existing.getQuantity() + quantity);
} else {
CartItem item = new CartItem(productId, product.getName(),
product.getPrice(), quantity);
cart.put(productId, item);
}
// 4. 放回 Session(Map 引用不变,但也可以显式 setAttribute)
session.setAttribute("cart", cart);
return "redirect:/cart";
}
/**
* 修改数量:更新购物车中某商品的数量。
*/
@PostMapping("/cart/update")
public String updateQuantity(@RequestParam Long productId,
@RequestParam int quantity,
HttpSession session) {
Map<Long, CartItem> cart = getCart(session);
CartItem item = cart.get(productId);
if (item != null) {
if (quantity <= 0) {
// 数量 <= 0 时删除该项
cart.remove(productId);
} else {
item.setQuantity(quantity);
}
session.setAttribute("cart", cart);
}
return "redirect:/cart";
}
/**
* 删除购物车项。
*/
@PostMapping("/cart/remove")
public String removeFromCart(@RequestParam Long productId,
HttpSession session) {
Map<Long, CartItem> cart = getCart(session);
cart.remove(productId);
session.setAttribute("cart", cart);
return "redirect:/cart";
}
/**
* 清空购物车。
*/
@PostMapping("/cart/clear")
public String clearCart(HttpSession session) {
session.removeAttribute("cart");
return "redirect:/cart";
}
/**
* 从 Session 获取购物车 Map。
*
* <p>如果 Session 中没有 "cart" 属性,返回一个空 HashMap。
* 使用 {@code synchronizedMap} 保证并发安全。</p>
*/
@SuppressWarnings("unchecked")
private Map<Long, CartItem> getCart(HttpSession session) {
Map<Long, CartItem> cart = (Map<Long, CartItem>) session.getAttribute("cart");
if (cart == null) {
cart = new java.util.concurrent.ConcurrentHashMap<>();
session.setAttribute("cart", cart);
}
return cart;
}
}
model/CartItem.java
package com.lihaozhe.ch17.model;
import java.math.BigDecimal;
/**
* 购物车项(第17章:会话状态对象)
*
* <p>理论知识 —— 购物车数据模型:
* 购物车本质是一个 Map<Long, CartItem>,key 是商品ID,value 是购物车项。
* 每次用户"加购"时,更新对应商品的购买数量;
* "删除"时,从 Map 移除该商品;
* "结账"时,从数据库校验库存并生成订单。</p>
*/
public class CartItem {
/**
* 商品ID
*/
private Long productId;
/**
* 商品名称(冗余存储,避免每次渲染都查 DB)
*/
private String productName;
/**
* 单价(下单时固定,不受后续 DB 价格变动影响)
*/
private BigDecimal price;
/**
* 购买数量
*/
private int quantity;
public CartItem() {
}
public CartItem(Long productId, String productName, BigDecimal price, int quantity) {
this.productId = productId;
this.productName = productName;
this.price = price;
this.quantity = quantity;
}
/**
* 小计 = 单价 × 数量。
*
* @return 金额小计
*/
public BigDecimal getSubtotal() {
return price.multiply(BigDecimal.valueOf(quantity));
}
public Long getProductId() {
return productId;
}
public void setProductId(Long productId) {
this.productId = productId;
}
public String getProductName() {
return productName;
}
public void setProductName(String productName) {
this.productName = productName;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
}
model/Product.java
package com.lihaozhe.ch17.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品实体(第17章)
*/
@TableName("t_ch17_product")
public class Product {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private BigDecimal price;
private String category;
private String description;
private Integer stock;
private LocalDateTime createTime;
private LocalDateTime updateTime;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getStock() {
return stock;
}
public void setStock(Integer stock) {
this.stock = stock;
}
public LocalDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
public LocalDateTime getUpdateTime() {
return updateTime;
}
public void setUpdateTime(LocalDateTime updateTime) {
this.updateTime = updateTime;
}
}
service/ProductService.java
package com.lihaozhe.ch17.service;
import com.lihaozhe.ch17.dao.ProductMapper;
import com.lihaozhe.ch17.model.Product;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 商品服务(第17章:基础 CRUD)
*/
@Service
public class ProductService {
private final ProductMapper productMapper;
public ProductService(ProductMapper productMapper) {
this.productMapper = productMapper;
}
public List<Product> findAll() {
return productMapper.selectList(null);
}
public Product findById(Long id) {
return productMapper.selectById(id);
}
}
dao/ProductMapper.java
package com.lihaozhe.ch17.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.lihaozhe.ch17.model.Product;
import org.apache.ibatis.annotations.Mapper;
/**
* 商品 Mapper(第17章)
*/
@Mapper
public interface ProductMapper extends BaseMapper<Product> {
}
application.yml
# 第17章:HttpSession 购物车
# 端口规则:8080 + 章号 17 → 8117
server:
port: 8117
spring:
application:
name: chapter17-cart
datasource:
url: jdbc:mysql://118.31.221.165:3306/sb_thymeleaf?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: root
password: lihaozhe
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 5
minimum-idle: 2
connection-timeout: 10000
sql:
init:
mode: always
thymeleaf:
cache: false
encoding: UTF-8
mode: HTML
check-template: true
check-template-location: true
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
schema.sql
-- 第17章:商品表
CREATE TABLE IF NOT EXISTS t_ch17_product (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '商品ID',
name VARCHAR(100) NOT NULL COMMENT '商品名称',
price DECIMAL(10,2) NOT NULL COMMENT '商品价格',
category VARCHAR(50) COMMENT '商品分类',
description VARCHAR(500) COMMENT '商品描述',
stock INT DEFAULT 0 COMMENT '库存',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第17章商品表';
data.sql
-- 第17章:商品种子数据
INSERT INTO t_ch17_product (name, price, category, description, stock) VALUES
('iPhone 16 Pro', 8999.00, '手机', '苹果最新旗舰手机', 100),
('Samsung Galaxy S24', 6999.00, '手机', '三星旗舰手机', 80),
('MacBook Air M3', 9999.00, '电脑', '苹果轻薄笔记本', 50),
('Dell XPS 13', 7999.00, '电脑', '戴尔轻薄本', 60),
('iPad Pro 11', 6999.00, '平板', '苹果平板电脑', 70),
('Sony WH-1000XM5', 2499.00, '耳机', '索尼降噪耳机', 120),
('Nintendo Switch OLED', 2399.00, '游戏机', '任天堂游戏机', 90),
('PlayStation 5', 4299.00, '游戏机', '索尼游戏机', 40),
('AirPods Pro 2', 1899.00, '耳机', '苹果无线耳机', 150),
('Xiaomi 14 Pro', 4999.00, '手机', '小米旗舰手机', 110),
('Huawei Mate 60 Pro', 6999.00, '手机', '华为旗舰手机', 75),
('LG C3 OLED TV', 12999.00, '电视', 'LG OLED 电视', 30);
templates/product-list.html
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>第17章 · 商品列表</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.9/dist/axios.min.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<nav class="navbar navbar-dark bg-dark px-4">
<span class="navbar-brand">第17章 · HttpSession 购物车商城</span>
<a th:href="@{/cart}" class="btn btn-outline-light btn-sm">🛒 购物车</a>
</nav>
<div class="container mt-4">
<div class="row row-cols-1 row-cols-md-3 g-4">
<!--
th:each 遍历 products 列表,变量为 product
-->
<div class="col" th:each="product : ${products}">
<div class="card h-100 shadow-sm">
<div class="card-body d-flex flex-column">
<h5 class="card-title" th:text="${product.name}">商品名</h5>
<span class="badge bg-info mb-2" th:text="${product.category}">分类</span>
<p class="card-text text-danger fw-bold" th:text="'¥' + ${product.price}">价格</p>
<p class="card-text text-muted small" th:text="'库存:' + ${product.stock}">库存</p>
<p class="card-text text-muted flex-grow-1" th:text="${#strings.abbreviate(product.description, 50)}">描述</p>
<!-- 加购表单:POST 到 /cart/add -->
<form th:action="@{/cart/add}" method="post" class="mt-auto">
<!-- 隐藏域:传递商品ID -->
<input type="hidden" name="productId" th:value="${product.id}">
<div class="input-group mb-2">
<!-- 数量输入框 -->
<input type="number" name="quantity" value="1" min="1" class="form-control form-control-sm">
<button type="submit" class="btn btn-primary btn-sm">加购</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
templates/cart.html
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>第17章 · 购物车</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.9/dist/axios.min.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<nav class="navbar navbar-dark bg-dark px-4">
<span class="navbar-brand">第17章 · 购物车</span>
<a th:href="@{/}" class="btn btn-outline-light btn-sm">继续购物</a>
</nav>
<div class="container mt-4" th:if="${not #lists.isEmpty(cartItems)}">
<!-- 购物车表格 -->
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>商品</th>
<th>单价</th>
<th>数量</th>
<th>小计</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<!--
遍历购物车项,变量为 item
-->
<tr th:each="item : ${cartItems}">
<td th:text="${item.productName}">商品名</td>
<td th:text="'¥' + ${item.price}">单价</td>
<td>
<!-- 修改数量表单 -->
<form th:action="@{/cart/update}" method="post" class="d-inline">
<input type="hidden" name="productId" th:value="${item.productId}">
<input type="number" name="quantity" th:value="${item.quantity}"
min="1" class="form-control form-control-sm" style="width: 70px;">
<button type="submit" class="btn btn-sm btn-outline-secondary">更新</button>
</form>
</td>
<!-- 小计:调用 getSubtotal() 方法 -->
<td class="text-danger fw-bold" th:text="'¥' + ${item.subtotal}">小计</td>
<td>
<!-- 删除按钮 -->
<form th:action="@{/cart/remove}" method="post" class="d-inline">
<input type="hidden" name="productId" th:value="${item.productId}">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
</tbody>
<!-- 合计行 -->
<tfoot>
<tr class="table-info">
<td colspan="3" class="text-end fw-bold">合计(共 <span th:text="${totalCount}">0</span> 件):</td>
<td class="text-danger fw-bold fs-5" th:text="'¥' + ${totalAmount}">0</td>
<td></td>
</tr>
</tfoot>
</table>
<!-- 清空购物车 -->
<form th:action="@{/cart/clear}" method="post" class="mb-3">
<button type="submit" class="btn btn-outline-danger">清空购物车</button>
</form>
<!-- 结算按钮 -->
<button class="btn btn-success btn-lg w-100">结算</button>
</div>
<!-- 空购物车提示 -->
<div class="container mt-4" th:if="${#lists.isEmpty(cartItems)}">
<div class="alert alert-info text-center">
<h4>购物车为空</h4>
<a th:href="@{/}" class="btn btn-primary">去购物</a>
</div>
</div>
</body>
</html>
运行验证
cd sb-thymeleaf/chapter17-cart
mvn clean package -DskipTests
java -jar target/chapter17-cart-1.0.0.jar
# 浏览器访问 http://localhost:8117/
# 商品列表:http://localhost:8117/
# 点击"加购"加入购物车
# 购物车页:http://localhost:8117/cart
测试流程:
- 访问 http://localhost:8117/ 查看商品列表
- 在任意商品上调整数量,点击"加购"
- 浏览器自动跳转到 /cart 显示购物车内容
- 修改数量、删除项、清空购物车,观察变化
注意:
- 表
t_ch17_product会在启动时自动创建 - 数据初始化:12 个商品种子数据
- 购物车数据保存在 Session 中,刷新浏览器(同一 Session)数据不丢失
- 打开新浏览器(新 Session)会获得独立购物车
页面效果
以下截图均为本地启动后浏览器真实渲染结果。


常见坑
-
Session 序列化问题:如果购物车对象需要跨服务共享(如 Redis 集群),必须实现
Serializable接口,否则序列化会失败。 -
并发修改:如果是高并发场景,Session 中的 Map 应该用
ConcurrentHashMap或加锁。本章使用ConcurrentHashMap保证基本线程安全。 -
Session 内存泄漏:如果用户在 Session 中存过多数据(如超大购物车),会导致服务器内存耗尽。生产环境应:
- 设置 Session 过期时间(
server.servlet.session.timeout) - 或使用 Redis 共享 Session
- 或限制购物车大小
- 设置 Session 过期时间(
-
商品价格变动:购物车中存储的是"加购时的价格"。如果用户加购后商品降价,结算时仍然按原价。这是为了保护用户利益,但需要在结算逻辑中特别说明。
更多推荐



所有评论(0)