做图书商城系统这个选题说实话一开始我心里是有点嘀咕的。电商类系统算是JavaWeb里被做烂掉的方向网上随便一搜都是各种“学生管理系统”“商城系统”的代码但真能把一套基于SpringBoot的网上购书商城从前端页面到后台管理完整跑通、逻辑又经得起推敲的其实不多。尤其是图书这个垂直品类它有自己的一套业务规则库存要按批次管理、订单要处理预售后付、商品分类往往是多级嵌套、还有图书信息的ISBN唯一性校验这些细节和卖衣服卖数码是完全不一样的。这篇博文就围绕我手头这个项目——基于SpringBoot框架的图书销售商城系统从设计思路、数据库建模、核心业务实现到部署上线把整个链路从头到尾拆一遍顺便把我在开发过程中踩过的坑和排查记录一起放出来。内容既适合拿它做毕业设计的同学参考也适合想快速上手SpringBoot实战的开发者。我会尽量把“为什么这么做”讲清楚而不是只丢一堆代码。1. 项目整体设计与技术选型1.1 为什么用SpringBoot而不是SSH或SSM很多教材还在教SSHStruts2SpringHibernate或者SSMSpringSpringMVCMyBatis但说实话拉开一个真实项目SpringBoot早就成了绝对主流。这个项目选SpringBoot最大的理由倒不是“大家都在用”而是它解决了传统SSM配置地狱的问题。我最早用SSM写项目的时候光一个applicationContext.xml就要配数据源、配事务管理器、配Mapper扫描、配视图解析器中间稍微一个标签写错Tomcat一启动就报各种诡异的BeanCreationException。SpringBoot把这一切改成了约定大于配置application.yml里写几行配置数据源、Redis、MyBatis这些组件就能自动装配进来。对图书商城这种需要快速迭代的Web系统来说开发效率是肉眼可见的提升。具体到版本选择我用的是SpringBoot 2.7.18。这里特意提一下不是为了赶新潮2.7.x是SpringBoot 2.x系列的最后一个版本稳定性和第三方组件兼容性都经过充分验证。尤其是我要整合MyBatis-Plus、阿里云OSS、微信支付SDK这些第三方库它们的SpringBoot Starter大多是基于2.x版本适配的直接上3.x反而容易遇到javax到jakarta命名空间迁移的坑。spring: datasource: url: jdbc:mysql://localhost:3306/book_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 servlet: multipart: max-file-size: 10MB max-request-size: 50MB mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true这组配置里有两个细节值得注意。第一是MySQL连接串必须带上serverTimezone否则高版本MySQL驱动会报时区错误第二是MyBatis-Plus的驼峰映射数据库字段是book_name实体类属性是bookName开启这个配置后就不用手写一堆resultMap了。1.2 功能模块划分与数据库设计思路图书商城系统不能只做一个简单的CRUD否则就失去了“商城”的意义。我在设计这个系统时把功能拆成了前台和后台两大块。前台面向普通用户核心链路是注册登录 → 浏览图书 → 搜索筛选 → 加入购物车 → 下订单 → 模拟支付 → 查看订单状态。后台面向管理员核心链路是图书管理上架/下架/库存调整、分类管理、订单处理发货/取消、用户管理、数据统计。数据库表设计上这张图我一直建议做类似项目的同学先画清楚再动代码t_user用户表字段包括user_id、username、passwordBCrypt加密存储、phone、email、avatar、status。t_category图书分类表支持多级分类使用parent_id做自关联level字段标识层级。t_book图书表核心字段是book_id、book_name、isbn唯一索引、author、publisher、publish_date、price、stock、sales、cover_img、description、status。t_cart_item购物车表记录user_id和book_id的关联关系加上quantity字段。t_order订单表订单号order_no用时间戳随机数生成total_amount、status、create_time、pay_time。t_order_item订单明细表一个订单对应多条记录保存下单时的图书快照。这里我要特别强调一个细节订单明细表必须冗余图书的部分字段比如书名、单价、封面图。为什么因为图书信息后续可能被管理员修改你是无法接受用户查看历史订单时发现书名和价格对不上的。这也是电商系统设计里最基本的“快照”思想做项目时容易忽视但面试官问起来这绝对是加分项。2. 核心功能实现与关键代码2.1 用户注册登录与JWT Token鉴权图书商城的用户体系不需要做到像社交平台那么复杂但安全性的底线还是要守住的。我的做法是注册时密码用BCrypt加密再存库登录成功后签发JWT Token前端把Token存到localStorage后续所有需要身份的接口通过拦截器校验Token。BCrypt加密的好处是不需要自己维护盐值每次加密生成的盐是随机嵌入到密文里的从根本上避免了MD5固定盐被彩虹表击穿的风险。在SpringBoot里集成BCrypt很简单// 注册时加密 String encodedPwd BCrypt.hashpw(user.getPassword(), BCrypt.gensalt()); user.setPassword(encodedPwd); // 登录时校验 boolean isMatched BCrypt.checkpw(rawPassword, user.getPassword());JWT这块我用的是jjwt 0.9.1版本生成Token时把userId和username放进去设置2小时过期时间。拦截器在请求进入Controller之前解析Token如果解析失败就直接返回401状态码。这里有个小坑需要提醒大家拦截器只拦截需要登录的接口比如购物车、下单、查询订单这些路径。静态资源和登录注册接口必须放行否则前端页面都加载不出来。Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login, /api/user/register) .excludePathPatterns(/api/book/**) .excludePathPatterns(/static/**, /error); }为什么图书查询接口要放行因为商城系统的图书浏览是公开给游客的用户可以先逛再决定要不要登录购买。如果一进来就被拦在登录页转化率会非常难看。这个逻辑看上去简单但很多同学做项目时容易把权限控制搞成一刀切。2.2 图书检索与多条件筛选实现图书商城的检索功能直接决定用户能不能快速找到想买的书。我实现了两种检索方式关键词模糊搜索和分类价格区间筛选。关键词搜索的SQL写法用的是MySQL的LIKE配合MyBatis-Plus的QueryWrapper来构造条件LambdaQueryWrapperBook wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(keyword), Book::getBookName, keyword) .or(StringUtils.hasText(keyword), w - w.like(Book::getAuthor, keyword)) .and(w - w.eq(categoryId ! null, Book::getCategoryId, categoryId)) .between(minPrice ! null maxPrice ! null, Book::getPrice, minPrice, maxPrice) .eq(Book::getStatus, 1) // 只查上架状态的图书 .orderByDesc(Book::getSales);注意这里的or和and嵌套写法是MyBatis-Plus里比较容易写错的地方。如果不加w -这个子查询包装生成的SQL条件会出现括号错乱查出来的数据就会有问题。分页我用的是MyBatis-Plus自带的分页插件。网上很多教程都停留在PageHelper的用法但MyBatis-Plus内置分页足够好用不用额外引包。只需要配置一个分页插件拦截器即可Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }前端页面通过PageT对象接收返回结果包含当前页数据、总记录数、总页数等。首页的图书推荐区我额外做了个按销量和发布时间综合排序的逻辑ORDER BY sales DESC, publish_date DESC让热销新书有机会排到前面。其实准确的排序逻辑可以引入权重分比如sales * 0.7 (现在时间 - publish_date)/某天数的归一化值 * 0.3但毕业设计阶段用SQL排序已经能出效果了。2.3 购物车与订单的库存扣减一致性购物车模块本身不复杂就是增删改查但订单生成时涉及的库存扣减一致性是整个系统最容易出Bug的地方。我的实现逻辑是用户提交订单 → 系统先校验购物车里的图书是否还有库存 → 有库存就扣减库存、生成订单和订单明细 → 清空已下单的购物车项 → 返回订单号。这里反复踩坑的核心问题是并发场景下的超卖。如果只是简单地在Service层写“查库存、判断够不够、再update”两个用户同时下单时可能都查到了库存1然后都扣减成功但实际库存变成了-1这就是典型的超卖。解决办法我选了最稳妥的两种方案结合使用方案一数据库乐观锁。在图书表增加version字段UPDATE t_book SET stock stock - 1, version version 1 WHERE book_id ? AND version ?。执行后返回受影响的行数如果为0说明版本冲突重新读取最新库存再试。方案二MySQL原子更新。直接利用stock quantity这个条件UPDATE t_book SET stock stock - #{quantity}, sales sales #{quantity} WHERE book_id #{bookId} AND stock #{quantity}影响行数为0就说明库存不足抛异常回滚整个事务。这个方案从机制上杜绝了超卖因为数据库层面的stock quantity条件在并发情况下只有一个事务能成功。我在OrderServiceImpl里用Transactional注解包裹下单方法配合方案的原子更新实测用JMeter模拟50个并发用户同时抢同一本书库存数据始终保持正确。2.4 管理员后台与文件上传处理管理员的图书管理模块除了基本的增删改查还涉及图书封面的文件上传。当时我把图片存到本地磁盘通过虚拟路径映射对外提供访问没有外接OSS因为毕设项目没有域名和桶资源。文件上传这部分的坑在路径问题上。Windows开发环境下本地路径怎么写都没问题但部署到Linux服务器上直接写D://upload/显然是要报错的。我改成在配置文件里设置上传路径然后通过配置类映射成静态资源file: upload-dir: /data/book-mall/upload/Configuration public class WebConfig implements WebMvcConfigurer { Value(${file.upload-dir}) private String uploadDir; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadDir); } }上传功能本身用的是SpringBoot自带的MultipartFile接口PostMapping(/admin/book/uploadCover) public Result uploadCover(RequestParam(file) MultipartFile file) { // 校验文件类型只允许jpg/png String originalFilename file.getOriginalFilename(); String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); if (!Arrays.asList(.jpg, .jpeg, .png).contains(suffix.toLowerCase())) { return Result.error(仅支持jpg/png图片); } // 防止文件名重复重命名为UUID String newName UUID.randomUUID().toString().replace(-, ) suffix; File dest new File(uploadDir, newName); file.transferTo(dest); return Result.success(/upload/ newName); }这里有个隐藏问题transferTo在文件已经存在时会覆盖所以文件名必须用UUID确保唯一性。另一个坑是上传文件的目录如果不存在transferTo会直接抛异常所以要先File.mkdirs()。3. 实战中的坑与排查记录3.1 分页查询时前端页码对不上上线测试时发现一个很隐蔽的问题前端展示当前页是第1页、第2页但后台接收的pageNum参数是1、2没问题而我在Service层又对pageNum做了一次减1操作导致查询结果变成第0页和第1页总页数也错位。排查思路先看前端axios请求源码发现请求参数是pageNum而后端Controller用的是page接收。参数名对不上前端以为传了page1实际上后端拿到的是pageNum1的默认值。这个算低级错误但很容易被忽略。解决方式就是统一参数命名前后端都用pageNum和pageSize不要一会儿用page一会儿用current。3.2 拦截器放行规则引发前后端联调失败有几天联调时前端总是反馈注册接口拿不到数据。打开控制台一看跨域报错但后端明明配置了CORS过滤器。仔细一查拦截器把OPTIONS预检请求拦截了。浏览器的跨域请求分为简单请求和预检请求当接口使用POST且Content-Type为application/json时浏览器会先发一个OPTIONS请求确认服务器是否允许跨域。如果拦截器拦截了这个OPTIONS请求并直接返回401那么浏览器就会判定跨域失败。解决办法是在拦截器中直接放行OPTIONS请求if (OPTIONS.equals(request.getMethod())) { return true; }这个坑特别经典前后端分离项目只要配了JWT拦截器就一定会遇到写在这里给大家提个醒。3.3 订单支付状态与回调幂等性设计我最初做“模拟支付”功能时直接在前台点击付款按钮后调用后端接口把订单状态从“待付款”改成“已付款”。后来考虑到这个系统要演示完整闭环我又引入了模拟支付回调的逻辑就发现一个问题回调接口被重复调用时订单状态会被重复更新甚至把已发货的订单又重置回已付款。解决思路是保证回调接口的幂等性。无论回调被调用多少次只有在订单状态是“待付款”时才执行更新逻辑如果订单已经处于“已付款”或“已发货”状态接口直接返回成功并忽略本次修改。public void handlePayCallback(String orderNo) { Order order orderService.getByOrderNo(orderNo); if (order null) { throw new BizException(订单不存在); } // 核心状态机校验只有待付款才能流转到已付款 if (!OrderStatus.WAIT_PAY.getCode().equals(order.getStatus())) { return; } order.setStatus(OrderStatus.PAID.getCode()); order.setPayTime(LocalDateTime.now()); orderService.updateById(order); }这样做不仅避免了状态被反向覆盖也结合了前面提到的乐观锁版本号校验支付回调这个高频率接口安全了很多。3.4 一次内存溢出引发的思考压力测试时并发量上去后系统出现了OutOfMemoryError。一开始以为是代码泄漏死循环问题后来通过jstat命令观察发现是老年代占满了。排查发现是我在查询首页图书列表时一次性把所有图书都加载到内存再通过stream进行分页筛选。这个写法在数据量小的时候没问题数据量一大就直接把堆内存打爆。改成分页查询后问题立刻消失。这个教训让我养成了一个习惯凡是涉及列表接口一律在SQL层用limit分页坚决不在Java代码里做全量数据筛选。4. 项目部署与性能优化建议4.1 Docker部署SpringBoot应用的完整流程项目开发完毕考虑到要让系统在服务器上稳定跑起来我选择了Docker部署。Docker的好处是环境隔离不会因为服务器本地的JDK版本或者MySQL配置不同导致应用启动失败。我写的Dockerfile很简单FROM openjdk:8-jdk-alpine LABEL maintaineryourname WORKDIR /app COPY target/book-mall.jar /app/book-mall.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app/book-mall.jar, --spring.profiles.activeprod]为了将数据库、Redis和服务容器化编排我用docker-compose维护了三个服务version: 3 services: mysql: image: mysql:8.0 container_name: book-mall-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: book_mall TZ: Asia/Shanghai volumes: - /data/mysql:/var/lib/mysql ports: - 3306:3306 redis: image: redis:6.2 container_name: book-mall-redis ports: - 6379:6379 app: build: . container_name: book-mall-app depends_on: - mysql - redis ports: - 8080:8080 restart: always部署时有个细节需要注意容器里面的MySQL数据一定要挂载到宿主机目录否则容器一旦删除重建所有数据都会丢失。我在第一次部署时没挂载volumes后来清理容器时数据库直接清空还好有备份不然前期灌的测试数据全没了。4.2 缓存优化与索引优化的实践记录系统初期查询效率不太理想尤其是图书搜索接口数据量上万后模糊搜索耗时就明显上来了。我做了两步优化。第一步是在热点数据上加Redis缓存。首页推荐图书、热门图书榜单这些数据属于读多写少的热点数据每次查询都打MySQL既浪费连接也增加延迟。我用Spring的Cacheable注解配合Redis缓存设置10分钟过期。图书上下架、价格调整时要主动清除缓存避免用户看到脏数据。Cacheable(value book:hot, key top10, unless #result null) public ListBook getHotBooks() { return bookMapper.selectHotBooks(); }第二步是在数据库上补充索引。搜索的关键字命中book_name和author两个字段我为它们都建立了普通索引。分类筛选高频出现category_id也单独建立索引。排序用的sales段建立普通索引。索引不是越多越好因为插入和更新数据时索引也是要维护的但对一个以查询为主的商城系统来说关键字段索引带来的收益远大于开销。优化前后的云服务器压测数据对比如下指标优化前优化后图书搜索接口平均响应时间860ms45ms首页加载完整耗时1.2s0.3s数据库连接池活跃连接数峰值258单接口100并发成功率82%99.6%这组数据充分说明了缓存和索引在真实场景里的价值。4.3 配置环境隔离dev与prodSpringBoot的多环境配置是项目上线前必须处理好的一环。我建立了application-dev.yml和application-prod.yml两个配置文件dev环境使用本地MySQL和Redis开启SQL日志打印方便调式prod环境则使用云服务器上的数据库关闭日志输出启用最优连接池参数。启动时通过--spring.profiles.activeprod指定环境。这样在本地开发的代码直接切换profile就能部署到生产不需要改任何代码。另外一个容易忽略的点是生产环境的spring.datasource.password不要硬编码在配置文件里我用的是环境变量替代spring: datasource: password: ${DB_PASSWORD}这样即使配置文件泄露数据库密码也不会直接暴露安全性提升了一个档次。5. 额外踩坑与优化记录监控与安全项目部署上线后运行监控是必然要做的。SpringBoot Actuator是最基础的手段暴露/actuator/health和/actuator/metrics信息配合Spring Boot Admin可以做出一个简易的监控面板。安全方面除了前面说的BCryptJWT还要提一下XSS攻击过滤。图书评论、管理员公告这类用户输入文本的模块如果不做过滤用户可能嵌入script标签其他人访问页面时脚本就会执行。这是一个非常实用的防攻击思路我用SpringBoot的Filter过滤器加了一个简单的XSS过滤器将请求参数中的HTML标签转义为普通字符串。public class XssFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest req (HttpServletRequest) request; chain.doFilter(new XssHttpServletRequestWrapper(req), response); } }包装器里重写getParameter和getInputStream方法利用HtmlUtils.htmlEscape()将危险字符转义为安全实体。当时在网上查资料的时候还看到有人提到上传PDF文件时也可能携带XSS攻击载荷虽然图书商城通常只让传图片但接口层面做的白名单校验不能省。6. 写在最后的一点个人经验如果你准备从零做一个SpringBoot图书商城系统我的建议是先花两天时间把数据库表结构和接口文档设计清楚再动手写代码。很多同学上来就建实体类、写Mapper结果写了一半发现字段对不上逻辑混乱返工成本极高。这个项目做下来我个人最大的收获其实是电商系统里那些看上去简单的功能真正落到代码层面都要考虑并发、一致性、幂等这些底层问题。图书商城的购物车、订单、库存三条线串起来就是一个小型电商核心链路。能把这条链路跑通且不出现超卖你就已经超越了大多数只懂CRUD的开发者的水平。最后再分享一个小技巧写接口的时候每个接口都要明确它的幂等性设计即同一个请求重复执行多次结果是否一致。不只支付回调需要幂等下单接口最好也做重复请求校验。我在这里用的方案是让前端生成一个唯一的requestId后端通过Redis的setNx命令做去重同一个requestId只允许处理一次。这个小设计后来在很多真实业务场景里都派上了用场。
