☰
SpringBoot+Mybatis+Thymeleaf购书商城全流程实战与踩坑指南
2026/10/30 16:56:04 网站建设 项目流程

简介:这是一份基于SpringBoot、Mybatis、Thymeleaf和MySQL搭建的购书商城系统源码,定位明确:适合Java学习者、毕业设计学生,以及需要快速搭建电商原型页面的开发者。通过学习这份代码,可以理解用户注册登录、图书搜索与分类、购物车管理、订单提交和后台数据维护是如何串联的。压缩包共109个文件,大小只有4.81MB,包含35个Java源文件、10个HTML页面、9个JavaScript脚本、8个CSS样式表,以及SQL脚本、XML配置、Maven构建文件等,覆盖从数据库表结构、后端接口到前端模板的完整工程。目前已有51人学习,尤其适合对照源码做技术复盘。系统已实现浏览、检索、推荐、加购、下单、订单管理、用户管理等主要模块,界面采用Thymeleaf配合样式库搭建,结构清楚、可扩展,能够作为课程设计或毕业设计的直接参考,也为后续加入权限控制、支付接口留出了充足空间。

1. 购书商城系统的真实落地:zip包不是拿来解压就完事的

拿到一个标注着“SpringBoot + Mybatis + Thymeleaf + MySQL”的购书商城系统zip包,大部分人第一反应是解压、开IDEA、等Maven下载依赖,然后对着启动报错发呆。这个资源包我拆过不止一次,它就是一套完整的单体电商工程——前台购书、后台管理、订单流转全在一个进程里跑,没有微服务那套复杂治理,也没有前后端分离的联调成本。对刚学完SSM、正在做毕业设计或者想接外包练手的人来说,这是性价比很高的复现对象。本文不聊空概念,直接按“这套代码为什么这么组织、怎么跑起来、哪些坑我替你踩过”的顺序拆,每一条命令和配置都确保能落地,尤其是Mybatis的XML映射和Thymeleaf的页面渲染这两块,属于典型的“看着会、一跑就废”的区域。

2. 技术选型逻辑:这四个框架为什么能撑起一套完整商城

2.1 SpringBoot:告别SSM时期的配置地狱

在SSM时代,搭一个可运行的Web工程要写web.xml、spring-mvc.xml、mybatis-config.xml三份配置,还得手工协调十几个jar包的版本。SpringBoot做的核心事是“约定大于配置”,它用starter机制把内嵌Tomcat、数据源自动装配、Mybatis工厂统统收编进依赖里,一个带@SpringBootApplication的启动类就能把整个应用拉起来。对于购书商城这种场景,SpringBoot最大的价值不是“新潮”,而是把开发者的精力从配环境拽回到写业务逻辑上。

我一般会重点关注SpringBoot版本与Mybatis官方starter(mybatis-spring-boot-starter)的版本匹配,这个放后面踩坑章节细说。SpringBoot 3.x以后,javax.servlet包迁移到jakarta.servlet,很多老项目的登录拦截器、文件上传代码会直接编译失败,这属于那种“不翻车一次就记不住”的教训。

2.2 Mybatis:为什么不用JPA或纯JDBC

购书商城的SQL复杂度分布很不均匀:图书列表要分页、多条件筛选、按分类聚合,订单模块要连表查用户信息和图书快照,后台统计要写GROUP BY。这类场景下,Mybatis把SQL完整保留在Mapper XML里,写复杂查询时可以直接针对执行计划调优,而不像JPA那样需要调试Hibernate自动生成的SQL。纯JDBC的问题则是样板代码太多——ResultSet映射实体、手动关闭连接、异常处理,这些重复劳动在业务一多的时候非常消耗耐心。

Mybatis的#{}预处理参数天然防SQL注入,这是它比字符串拼接JDBC安全的地方。而${}用于动态排序字段或表名时风险极大,这个资源包里如果出现${},一定要检查传入值的白名单。Mybatis还有一个容易被忽略的价值:Mapper接口和XML绑定,改SQL不用动Java代码,对后期接外包维护来说,这个特性很实用。

2.3 Thymeleaf:服务端渲染的必要性

有人会问:现在都流行Vue + SpringBoot前后端分离,为什么还要用Thymeleaf?答案在项目定位里。这是一个单体应用,Thymeleaf服务端渲染的好处是:购物车数量、用户登录态、图书列表数据直接在服务端拼好,浏览器拿到的就是完整HTML,不需要等异步接口返回再画页面。更重要的是,Thymeleaf的语法(th:each、th:if、th:object、th:href)和JSP有相似之处,但又不依赖Servlet容器,SpringBoot对它有原生支持。

如果这个项目改成Vue分离架构,你就得额外处理跨域、Token鉴权、接口文档维护三件事,对学习性质的项目来说偏离了核心目标。Thymeleaf并不是没有缺点——页面模板和后端代码耦合较紧,复杂的前端交互写起来别扭,但作为商城这种以展示和表单为主的项目,它的试错成本最低。

2.4 MySQL:五张核心表的设计思路

表结构是这套资源的骨架,购书商城至少要拆出五类表:用户表(含用户名、密码、手机号、角色)、图书表(书名、作者、ISBN、价格、库存、封面图)、分类表、购物车表、订单表加订单项表。用户表和订单表是一对多,订单和订单项是一对多,图书和分类是多对一,购物车是用户和图书的多对多关联表。

索引设计上,订单表必须建user_id普通索引,否则后台管理查用户订单时会全表扫描;图书表的title字段要建普通索引,因为前台搜索走LIKE '%keyword%'时索引其实失效,但等值匹配(比如按ISBN查)索引能命中。库存字段建议用INT而不是VARCHAR,否则做扣减时的算术运算会有隐式转换的坑。

建表脚本长这样,字符集统一用utf8mb4,别问为什么不用utf8,等你在订单表里存入emoji表情再查不出来就懂了:

CREATE TABLE `book` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '书名', `author` varchar(100) DEFAULT NULL, `isbn` varchar(20) DEFAULT NULL, `price` decimal(10,2) NOT NULL DEFAULT '0.00', `stock` int(11) NOT NULL DEFAULT '0', `cover_url` varchar(500) DEFAULT NULL, `category_id` int(11) DEFAULT NULL, `status` tinyint(1) DEFAULT '1' COMMENT '1上架 0下架', PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_isbn` (`isbn`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意price用decimal而不是float,这是因为浮点数在金额计算上存在精度丢失,订单金额超过两位小数对不上账的时候,你会感谢这个选择。状态字段status用tinyint而不是varchar,既节省空间又方便在SQL里直接写WHERE status = 1。

3. 把项目跑起来:数据库导入到前后台联调的六步实操

3.1 环境准备与MySQL初始化

在你解压zip包之后,第一步不是开IDEA,而是先把环境对齐。推荐组合是JDK 1.8 + Maven 3.6+ + MySQL 5.7或8.0。如果你的机器装的是MySQL 8.x,要注意连接参数的时区设置,我后面会单独讲。

数据库导入用命令行最稳妥,Navicat可视化工具偶尔会因为SQL文件里包含DROP TABLE IF EXISTS语句而报错中断,命令行反而更干净:

mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS book_store DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -u root -p book_store < /path/to/book_store.sql

第一条命令创建数据库并指定utf8mb4字符集,第二条命令把SQL脚本导入。如果脚本里有外键约束,导入顺序就很重要——先建父表(用户表、分类表)再建子表(订单表、购物车表)。遇到导入报错时,不要急着改脚本,先用source命令逐条执行,定位到报错的那条SQL,绝大多数情况是表前缀或字段类型和预期不一致。

3.2 配置文件解读与应用参数调整

SpringBoot的配置全集中在src/main/resources/application.yml,这是整个项目的“总闸”。打开后你会看到数据源、Mybatis、Thymeleaf三块配置,我建议按这个顺序检查:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/book_store?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: your_password thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html encoding: UTF-8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.bookstore.entity configuration: map-underscore-to-camel-case: true

连接url里的serverTimezone=Asia/Shanghai和useSSL=false是针对MySQL 8.x的必备参数——前者解决时区差8小时的问题,后者避免SSL握手报错。map-underscore-to-camel-case开启后,数据库的user_name字段能自动映射到实体的userName,省去写大量resultMap的体力活。

3.3 启动验证与控制台日志判读

配置改完后,在项目根目录执行打包命令,这一步会同时校验代码有没有语法错误和依赖是否完整:

mvn clean package -DskipTests java -jar target/bookstore-0.0.1-SNAPSHOT.jar

控制台出现Tomcat started on port(s): 8080才是真正的启动成功。如果停在Starting...超过30秒,多半是数据库连接失败或者端口被占用。用netstat -ano | findstr 8080看端口占用是Windows下的快速排查方式。启动成功后访问http://localhost:8080/,正常应该跳到前台首页;后台入口一般是/admin/login。

3.4 前后台联调的路径比对

这套资源的前台和后台共用同一个Tomcat进程,区别只在URL前缀和登录态校验。前台路径走/book/list、/cart/add这类公开接口,后台路径走/admin/**,由拦截器统一校验管理员Session。联调时你要验证的关键链路是:用户注册 → 登录 → 搜书 → 加入购物车 → 下单 → 后台看到新订单 → 修改订单状态,这条链路走通,项目就算真正跑起来了。

4. 核心业务模块拆解:用户、图书、购物车、订单的数据流

4.1 用户模块:注册登录与Session状态管理

用户模块的核心不在Controller而在Service层的事务边界。注册时要校验用户名唯一性,登录时要对密码做比对,密码存储不能是明文——这个资源包里如果用的是MD5,建议你至少换加盐的SHA-256。在购书商城的简化场景里,登录态用Session存储是务实的做法:

@PostMapping("/login") public String login(String username, String password, HttpSession session, Model model) { User user = userService.login(username, password); if (user == null) { model.addAttribute("error", "用户名或密码错误"); return "login"; } session.setAttribute("loginUser", user); return "redirect:/book/list"; }

逻辑很直白:登录失败返回登录页并携带错误提示,成功则把用户对象塞进Session然后重定向到列表页。注意Controller返回String时用redirect:前缀是重定向,不加前缀是服务端转发到模板页面。这个区别直接影响浏览器地址栏的URL和刷新后的行为,很多新手在这里栽过跟头。

注册接口的业务逻辑还要处理两个隐藏点:用户名去重校验要放在Service层而不是Controller层,因为Controller只负责参数接收和视图跳转;密码加密要在进入数据库之前完成,而不是查出来再加密比较。常见的错误是把userService.register(user)放在@Transactional里却忘了处理唯一键冲突的DuplicateKeyException,结果前台永远提示“系统错误”而不是“用户名已存在”。

4.2 图书模块:首页展示与多条件搜索的SQL构造

图书列表页是Thymeleaf展示能力的主场,它的数据来源是BookMapper.xml里的一段动态SQL。多条件搜索(书名模糊、分类、价格区间)在Mybatis里靠<where>和<if>标签组合。

<select id="searchBooks" resultType="com.example.bookstore.entity.Book"> SELECT id, title, author, isbn, price, stock, cover_url, category_id, status FROM book <where> <if test="keyword != null and keyword != ''"> AND title LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="minPrice != null"> AND price &gt;= #{minPrice} </if> <if test="maxPrice != null"> AND price &lt;= #{maxPrice} </if> AND status = 1 </where> ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select>

这段SQL的要点有三个:第一,<where>标签会自动去掉第一个多余的AND,这是Mybatis的贴心设计;第二,&gt;和&lt;是XML中大于号和小于号的转义写法,直接写>会把XML标签结构搞坏;第三,LIMIT #{offset}, #{pageSize}是MySQL分页的标准写法,offset从0开始而不是从1。

分页查询除了手写LIMIT,也可以引入PageHelper插件。但引入后要注意:PageHelper只对它后面紧跟的那一条查询生效,如果你在PageHelper.startPage()和查询之间插了任何其他SQL操作,分页就会错乱,这在多表关联查询时尤其容易翻车。

页面渲染时,Thymeleaf用th:each遍历图书列表,th:href拼接详情页地址。值得留意的是封面图路径——数据库存的是相对路径如/images/books/1.jpg,页面加th:src="@{${book.coverUrl}}"时,这个路径会带上项目上下文前缀。如果你发现图片加载404,先看浏览器开发者工具里的实际URL,多半是路径少了上下文路径。

4.3 购物车与订单模块:事务与库存扣减的原子性

订单创建是整个项目里最考验事务功底的部分,它要同时完成四件事:校验库存、扣减库存、创建订单主表记录、创建订单项子表记录。这四个操作必须在一个事务里,否则会出现订单建了但库存没扣的严重数据不一致。

@Transactional(rollbackFor = Exception.class) public Order createOrder(Integer userId, List<CartItem> cartItems) { // 1. 生成订单主表 Order order = new Order(); order.setUserId(userId); order.setOrderNo(generateOrderNo()); // 时间戳+随机数 order.setStatus(OrderStatus.PENDING_PAYMENT.getCode()); order.setCreateTime(new Date()); orderMapper.insert(order); // 2. 遍历购物车项,扣库存并生成订单项 double totalAmount = 0; for (CartItem item : cartItems) { Book book = bookMapper.selectByIdForUpdate(item.getBookId()); if (book.getStock() < item.getQuantity()) { throw new RuntimeException("图书《" + book.getTitle() + "》库存不足"); } bookMapper.decreaseStock(book.getId(), item.getQuantity()); OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setBookId(book.getId()); orderItem.setPrice(book.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItemMapper.insert(orderItem); totalAmount += book.getPrice() * item.getQuantity(); } order.setTotalAmount(totalAmount); orderMapper.updateTotalAmount(order.getId(), totalAmount); return order; }

代码里有两个关键细节:第一,selectByIdForUpdate用了悲观锁FOR UPDATE,这是在高并发下单时防止超卖的必要手段——两个用户同时买最后一本书时,第一个事务锁住行,第二个事务必须等第一个提交才能读,因此不会出现库存扣成负数;第二,@Transactional(rollbackFor = Exception.class)是事务生效的正确姿势,如果只写@Transactional而不指定rollbackFor,运行时异常会回滚但Exception类不会被拦截。

这里也是事故高发区:如果decreaseStock的SQL里没有加WHERE stock >= #{quantity}的库存校验条件,并发下就会出现库存扣成负数。Mybatis的乐观锁做法是在表里加version字段,更新时带上WHERE version = #{version},影响行数为0则说明数据已被修改,需要重试。这个资源包里如果没有锁机制,你在答辩或演示时主动补上,反而是加分项。

5. 避坑指南:从依赖冲突到中文乱码的九条踩坑记录

5.1 启动报错:Invalid bound statement (not found)

现象:SpringBoot启动成功,但调用BookMapper接口时抛出org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)。
原因:Mybatis扫描不到Mapper XML文件。99%的情况是mapper-locations配置路径和实际的XML目录不一致,比如XML放在src/main/resources/mapper/下,配置却写成了classpath:mapper/*.xml没问题,但如果XML在java目录下就会扫描不到。还有一种情况是Mapper接口和XML文件的名字不一致。
解决:打开target/classes目录,确认编译后的XML是否存在于对应的mapper文件夹。如果不在,检查Maven的pom.xml是否漏了<resources>配置把XML排除掉了。这个检查能解决九成这类问题。

5.2 中文乱码:从连接参数到HTML页面的完整链路

现象:浏览器访问页面时中文标题正常,但从数据库读出来的书名和作者变成问号或乱码。
原因:字符集不一致,可能发生在三个环节——数据库表字符集、JDBC连接参数、Thymeleaf模板编码。数据库表用了utf8而连接url没有指定characterEncoding=utf8,或者模板页面没有加<meta charset="UTF-8">。
解决:按“库表字符集→连接参数→页面声明”顺序逐一排查。首先确认建表语句用的是utf8mb4,然后检查连接url是否带useUnicode=true&characterEncoding=utf8,最后确认页面head标签里有<meta charset="UTF-8">。如果前端页面提交的中文到后端变成乱码,还要检查Tomcat的URI编码配置,SpringBoot在application.yml里加server.tomcat.uri-encoding: UTF-8。

5.3 静态资源404:CSS和JS加载不出来

现象:页面打开后样式全丢,控制台报404 /css/style.css找不到。
原因:SpringBoot默认把src/main/resources/static作为静态资源根目录,但Thymeleaf模板里写的是/css/style.css的绝对路径。如果项目设置了server.servlet.context-path=/bookstore,那么实际静态资源地址变成/bookstore/css/style.css,模板如果写死了不具备上下文前缀的路径就会404。
解决:所有静态资源引用改用Thymeleaf的th:href="@{/css/style.css}",@{}表达式会自动拼接上下文路径。另一种做法是保持绝对路径但去掉context-path,但这会导致部署到生产环境时无法和其他应用共存。

5.4 MySQL 8.x连接报错:SSL与时区问题

现象:启动时报WARN: Establishing SSL connection without server's identity verification is not recommended或The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。
原因:MySQL 8.x默认开启SSL连接,且时区设置依赖系统时区。当你用5.x的驱动去连8.x的库,或者url里缺少时区参数时,驱动无法完成握手。
解决:驱动依赖里确保用的是mysql-connector-java8.x版本,连接url加两个参数useSSL=false&serverTimezone=Asia/Shanghai。如果你用的是SpringBoot 2.7.x,它默认管理的MySQL驱动版本是8.0.x,不需要额外指定。这里有个小细节:serverTimezone=Asia/Shanghai不要写成GMT+8,某些驱动版本下会导致日期读取偏移。

5.5 Thymeleaf缓存导致页面修改不生效

现象:改了HTML模板,刷新浏览器页面还是老样子。
原因:Thymeleaf默认开启页面缓存,特别是非debug模式下,模板只在第一次请求时解析一次,后续直接读缓存。
解决:开发环境在application.yml里设置spring.thymeleaf.cache: false,同时用Ctrl+F5强制刷新浏览器清缓存。生产环境要反过来打开缓存,否则每次请求都重新解析模板,性能损耗很大。这个坑最迷惑的地方在于:后端代码改动需要重启,而前端模板改动在devtools热部署下不一定触发重启,于是你看到的永远是旧页面。

5.6 Maven依赖冲突:多个Mybatis版本共存

现象:启动时抛NoClassDefFoundError: org/apache/ibatis/session/SqlSessionFactory。
原因:项目同时引入了mybatis-spring-boot-starter和手动的mybatis坐标,或者不同依赖传递了两个版本的Mybatis核心包。Maven的依赖仲裁选了错误的那个版本,接口签名不兼容直接启动失败。
解决:在IDEA的Maven项目结构里搜索mybatis,把多余的依赖排除掉。常见做法是只保留mybatis-spring-boot-starter,去掉显式声明的mybatis和mybatis-spring,因为starter已经把这两个传递引入了。

5.7 跨day修改密码和退出登录的Session失效

现象:用户修改密码后,旧Session还能继续操作;退出登录后再点浏览器后退,还能看到之前的购物车页面。
原因:修改密码时没有主动失效旧Session,退出登录时只删了Cookie里的SessionID而没有调用session.invalidate()。
解决:修改密码成功后立即session.invalidate()再重新登录;退出登录的Controller里调用session.invalidate()后重定向到首页。也可以注册HttpSessionListener在Session销毁时做清理,但单体项目用最简单的方式就够。

5.8 SQL注入隐患:${}的误用

现象:搜索框输入1' OR '1'='1,回来后端日志发现SQL语句变成了WHERE id = 1' OR '1'='1。
原因:Mapper XML里用了${keyword}而不是#{keyword}。${}是纯字符串拼接,#{}是预处理参数占位符。
解决:所有用户可输入的参数一律用#{},只有排序字段、表名这类无法预编译的少数场景才用${},且这些字段必须经过白名单校验。你可以在网上搜到一堆因为这个翻车的案例,这条规则应该刻进DNA里。

5.9 购物车数量负数与重复提交

现象:用户疯狂点击“加入购物车”按钮,结果购物车表里出现负数数量或重复记录。
原因:前端没有做按钮防重复提交,后端的插入语句没有唯一约束兜底。购物车表如果没建user_id + book_id的唯一索引,每次点击都会插入新记录。
解决:在购物车表的(user_id, book_id)上建唯一索引,SQL里用INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + 1,这样既能合并同书籍的记录,又防止了并发下的重复插入。前端再配合节流函数限制点击频率,这个坑就堵死了。

6. 进阶玩法:从接口调试到缓存与安全加固

项目能跑通只能算入门,把它调整到“可演示、可答辩、可上线”的状态才是真正的复现目标。我建议你按下面三步做增强。

第一步,用Postman把订单流程验证一遍。写一个简单的测试流程:POST请求登录接口拿到Session Cookie,然后带Cookie请求添加购物车接口、下单接口,最后用@Transactional的测试用例验证库存扣减和订单数据一致性。这一套跑通后,你对整个数据流的掌握程度会远超读十遍源码。

第二步,给图书列表接口加上Mybatis二级缓存。在BookMapper.xml里加<cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/>,缓存命中后的查询性能提升非常明显。但要注意:图书更新时要手动清除缓存,否则后台改了价格前台还是旧数据。最简单的方法是在updateBook的SQL后调用sqlSession.clearCache(),或者直接依赖Mybatis的内置缓存机制更新后自动刷新。事务中查询也会绕开二级缓存,这点要心里有数。

第三步,也是我最想强调的——给管理员接口补上权限校验。资源包里如果只有登录拦截器,至少要手动加上角色判断:在Interceptor里从Session取用户对象,检查role字段是否为ADMIN,不是则重定向到403页面。别忘了做密码加密升级,把MD5换成BCrypt,Spring Security里的BCryptPasswordEncoder可以单独拿来用。我后来接外包时遇到过不止一次,项目交付时客户要求演示“权限不足”的拦截场景,这套加固逻辑直接迁移过去就能用。

从那以后,我每次拿到一个SpringBoot练手项目,都会强制自己走一遍“先修依赖版本、再验字符集、后查权限”的流程,顺序不颠倒,这个习惯帮我筛掉了很多货不对板的资源。这套购书商城的zip包,按以上方式跑通后,你对SSM整合的理解深度会明显拔高,拿出去演示完全站得住脚。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询