简介:这份基于SSM框架的网上书店系统毕业设计文档,面向计算机相关专业学生及Java Web初学者,提供从选题到实现的完整项目参考。全文围绕Spring、SpringMVC与MyBatis的整合开发展开,覆盖用户登录注销、前台商品浏览与搜索、在线购买、订单管理,以及后台商品类别、商品信息、订单和用户管理等模块,采用MySQL存储数据,开发环境为Myeclipse,并区分管理员与普通用户两类角色,功能划分清晰。资源包内含1个docx文档,大小约734KB,以论文正文为主体,包括中英文摘要、目录、绪论、系统分析与设计、数据库设计及功能实现等章节,结构完整,便于直接借鉴论文框架与功能设计思路。目前已有48人学习关注,适合需要完成课程设计、期末大作业或毕业设计的学生参考,也可作为SSM入门练手项目的需求梳理与文档撰写模板。
1. 从零搭一套 SSM 网上书店系统,先把这三个问题想明白
SSM 网上书店系统是 JavaWeb 练手项目里被反复搜索的题目之一。有人以为它只是把图书增删改查堆一遍,真上手才发现:图书检索要支持分类加关键字加价格区间的组合分页,下单要保证库存不被超卖,购物车在登录前后状态还不一样——每一处都能把只会写单表 CRUD 的人卡住。它适合两类人:需要一套完整业务闭环来串起 Spring、SpringMVC、MyBatis 的初学者,以及想借真实场景理解声明式事务、动态 SQL 与分层解耦的开发者。
这里的 SSM 不是三个孤立框架拼盘。Spring 管 IoC 容器和事务边界,SpringMVC 管 HTTP 入口与参数绑定,MyBatis 管把 SQL 从 Java 代码里搬出来。三者靠一个 SqlSessionFactory 和一套包结构缝合,缝合方式才是这套系统真正的技术含量——配错了,事务不生效,Mapper 找不到,前端传的中文变乱码。
下面从库表设计一路走到可下单、可查订单、能扛住并发扣库存的工程。中间给出可直接抄的依赖、配置、Mapper 与排错路径,避免停在「能跑 demo、一改需求就崩」的阶段。
2. SSM 网上书店系统的库表设计与 Maven 工程骨架
2.1 图书、分类、订单、用户四张核心表怎么定
先定表再定代码。新手最常见的翻车方式是先把实体类写完,再去想订单和图书怎么关联,结果下单时发现要么库存没地方放,要么订单里显示的是图书当前价而不是下单时的价。网上书店的最小可用模型是五张表,其中四张是主干,一张是明细。
| 表名 | 核心字段 | 设计要点 |
|---|---|---|
| t_user | id, username, password, salt, phone, create_time | username 建唯一索引;密码存 salted MD5,别存明文 |
| t_category | id, name, parent_id, sort | parent_id 留 0 表示一级分类,为后期多级预留 |
| t_book | id, title, author, publisher, isbn, price, stock, category_id, status | price 用 decimal(10,2);stock 加无符号约束;status 做软删除 |
| t_order | id, order_no, user_id, total_amount, status, receiver, phone, create_time | order_no 唯一且非自增,用来对外暴露订单号 |
| t_order_item | id, order_id, book_id, book_title, price, quantity | price 必须存下单快照,图书调价后历史订单金额不能变 |
t_order_item里冗余book_title和price是刻意为之:订单是历史凭证,图书改名、下架、调价都不应该回头改写已生成的订单。而t_book.stock用无符号整型,是为了让数据库在最坏情况下兜底——应用层扣成负数直接抛错,总比悄悄把库存写成 -3 要好。
索引不用贪多。t_book上按(category_id, status)建联合索引,因为列表页永远是「先按分类筛,再按上架状态筛」;t_order上按(user_id, create_time)建索引,支撑「我的订单」按时间倒序翻页。title做模糊查询时前缀通配符会走不了索引,练手项目数据量小可以忍,数据量上来就得换全文索引或搜索引擎。
2.2 pom.xml 依赖与 Spring 整合 MyBatis 的关键配置
版本对齐是第一步。Spring 5.3.x 搭配 MyBatis 3.5.x 和 mybatis-spring 2.0.x 是踩坑最少的组合,Servlet API 用 4.0.1,打包方式必须是 war 而不是 jar,否则 Tomcat 不认。
<properties> <spring.version>5.3.39</spring.version> <mybatis.version>3.5.16</mybatis.version> <mybatis.spring.version>2.1.2</mybatis.spring.version> </properties> <dependencies> <!-- Spring 核心:IoC 容器 + 事务 + Web MVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis 与 Spring 的粘合层,负责 SqlSession 的创建和注入 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>${mybatis.spring.version}</version> </dependency> <!-- 连接池与驱动 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.23</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>Spring 里整合 MyBatis 的核心就是三个 Bean:数据源、SqlSessionFactoryBean、MapperScannerConfigurer。
<!-- applicationContext-dao.xml --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <!-- 初始化连接数,练手项目 5 足够,配大反而拖慢启动 --> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <!-- 别名包:XML 里 resultType 可以直接写 Book 而不是全限定名 --> <property name="typeAliasesPackage" value="com.bookstore.entity"/> <!-- 关键:告诉 MyBatis 去哪里找 Mapper XML,配错了就是 Invalid bound statement --> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <!-- 下划线自动转驼峰,create_time 映射到 createTime 不用手写 resultMap --> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> <property name="logImpl" value="org.apache.ibatis.logging.stdout.StdOutImpl"/> </bean> </property> </bean> <!-- 扫描 Mapper 接口,自动生成代理实现,省掉手写 DaoImpl --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.bookstore.mapper"/> </bean>mapperLocations和basePackage是最容易写错的一对参数:前者管 XML 文件路径,后者管 Java 接口。两者必须能一一对应上同名接口,否则启动时报Invalid bound statement (not found),且不会在 Spring 启动阶段报错,而是等到第一次调用才炸。mapUnderscoreToCamelCase建议一直开着,能省掉大量重复的 resultMap 映射代码。
2.3 包结构与分层职责:别让 Controller 直接碰 Mapper
分层不是形式主义,它是为了让事务有地方挂。工程按职责切成五层,每个包只依赖它下面的那一层。
com.bookstore ├── controller // 只做参数接收、校验、返回视图或 JSON ├── service // 业务编排 + 事务注解,订单和库存在这层合并 │ └── impl ├── mapper // MyBatis 接口,只声明方法,不写业务判断 ├── entity // 与表一一对应,字段名和列名靠驼峰规则映射 ├── dto // 前端传参对象,比如 BookQueryDTO、OrderDTO └── common // 统一返回体 Result、分页对象 PageResult、常量判断分层是否合理有个简单标准:把 Controller 换成手机 App 接口、把 MyBatis 换成 JPA,业务代码要不要重写?如果下单逻辑里混着HttpServletRequest,那说明层级漏了。Controller 层只允许出现@RequestParam、@RequestBody这类绑定注解和一次 service 调用,任何if (stock < 1)这类判断都应该下沉到 service。
3. 用 MyBatis 动态 SQL 和 Spring 事务实现检索、购物车与下单
3.1 图书多条件分页检索的 Mapper 与动态 SQL
书店列表页的查询条件是动态的:可能只有分类,也可能分类加关键字加价格区间一起给。用字符串拼 SQL 会被注入,用WHERE 1=1 AND ...又难维护,MyBatis 的<where>加<if>是标准解法。
<!-- BookMapper.xml --> <select id="selectByCondition" resultType="Book"> SELECT id, title, author, publisher, price, stock, category_id, cover FROM t_book <where> status = 1 <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> <!-- concat 在数据库侧拼 %,避免 Java 里做字符串拼接 --> AND (title LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> </where> ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select> <!-- 同样的条件要查总数,否则分页控件算不出总页数 --> <select id="countByCondition" resultType="int"> SELECT COUNT(*) FROM t_book <where> status = 1 <if test="categoryId != null">AND category_id = #{categoryId}</if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%')) </if> </where> </select>参数对应关系需要说清楚:offset不是页码,而是(pageNum - 1) * pageSize,这个换算放在 service 里做,Mapper 只认偏移量。#{offset}用#{}而不是${},前者走 PreparedStatement 占位符,后者是字符串替换,ORDER BY ${column}这种写法如果 column 来自前端就是注入漏洞。排序字段必须来自白名单,不能直接透传。
还有一个容易忽略的点:countByCondition和selectByCondition的<where>条件必须严格一致,否则会出现「列表有数据但总数是 0」或者分页控件多出一页空白。稳妥做法是把公共条件抽成<sql id="queryCondition">片段,两处<include>引用。
3.2 下单时用 @Transactional 把订单与库存绑成一个原子操作
下单要连续做四件事:校验库存、写订单主表、写订单明细、扣减库存。任何一步失败,前面写的数据都必须回滚,否则会出现订单生成了但库存没扣,或者库存扣了订单没落库。
@Service public class OrderServiceImpl implements OrderService { @Autowired private BookMapper bookMapper; @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper orderItemMapper; @Override @Transactional(rollbackFor = Exception.class) public String createOrder(Long userId, List<CartItemDTO> items) { BigDecimal total = BigDecimal.ZERO; // 先汇总金额,再落库,避免边算边写 for (CartItemDTO item : items) { Book book = bookMapper.selectById(item.getBookId()); if (book == null || book.getStatus() != 1) { throw new BusinessException("图书已下架:" + item.getBookId()); } total = total.add(book.getPrice().multiply( BigDecimal.valueOf(item.getQuantity()))); } String orderNo = "BS" + System.currentTimeMillis(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(total); order.setStatus(0); // 0 待支付 orderMapper.insert(order); for (CartItemDTO item : items) { Book book = bookMapper.selectById(item.getBookId()); // 条件扣减:库存扣到负数会返回 0 行,据此判断超卖 int affected = bookMapper.reduceStock( item.getBookId(), item.getQuantity()); if (affected == 0) { throw new BusinessException("库存不足:" + book.getTitle()); } OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setBookId(book.getId()); oi.setBookTitle(book.getTitle()); // 价格快照 oi.setPrice(book.getPrice()); oi.setQuantity(item.getQuantity()); orderItemMapper.insert(oi); } return orderNo; } }扣库存的 SQL 是防超卖的关键,不能在 Java 里先查再算再更新:
<update id="reduceStock"> UPDATE t_book SET stock = stock - #{quantity} WHERE id = #{bookId} AND stock >= #{quantity} </update>WHERE stock >= #{quantity}让扣减本身带条件,靠数据库的行锁保证原子性。如果只在 Java 里判断book.getStock() >= quantity,两个线程同时读到库存 1,就会双双通过校验,最终库存变成 -1。affected == 0时抛异常触发回滚,这几行是整个下单流程里最值钱的部分。
@Transactional的rollbackFor = Exception.class必须显式写。默认只对 RuntimeException 回滚,自定义的BusinessException如果继承了 Exception 而不是 RuntimeException,库存不足时不会回滚,前面的订单主表就白写了。另外这个方法必须是 public,且不能在本类内部被this.createOrder()直接调用,否则走不到代理对象,事务同样失效。
3.3 SpringMVC 控制器里的参数绑定与统一返回
Controller 层尽量薄,只做三件事:接参数、调 service、包返回体。
| 场景 | 注解 | 注意点 |
|---|---|---|
| 列表页多条件查询 | @RequestParam+ DTO | 分页参数给默认值,pageNum 从 1 开始 |
| 加入购物车 | @RequestBody+ JSON | 需配置 Jackson,别漏<mvc:annotation-driven/> |
| 下单 | @RequestBody+@Valid | 数量加@Min(1)校验,防负数绕过 |
| 我的订单 | @SessionAttribute取用户 | 登录态统一从 session 拿,别信前端传的 userId |
@RestController @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/create") public Result<String> create(@RequestBody @Valid OrderCreateDTO dto, HttpSession session) { User login = (User) session.getAttribute("LOGIN_USER"); if (login == null) { return Result.fail(401, "请先登录"); } // userId 只从 session 取,前端传的都要丢掉,否则能替别人下单 String orderNo = orderService.createOrder(login.getId(), dto.getItems()); return Result.ok(orderNo); } }Result是自定义的统一返回体,包含 code、msg、data 三个字段。这样做的好处是前端拦截器只判断 code,业务异常和系统异常都能走同一套处理逻辑。登录用户 ID 一定要从 session 或 token 里解出来,让前端传 userId 是常见的安全漏洞,改一个参数就能看别人的订单。
4. 网上书店系统跑通与排错:Tomcat 部署、事务失效与空指针定位
4.1 web.xml 与前端控制器的启动配置
SpringMVC 的入口是DispatcherServlet,它的初始化顺序决定了配置文件怎么加载。
<!-- web.xml --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <!-- contextConfigLocation 指定 SpringMVC 自己的容器配置 --> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <!-- 拦截 / 下所有请求,静态资源要另外放行 --> <url-pattern>/</url-pattern> </servlet-mapping> <!-- Spring 父容器:管 service、事务、数据源 --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- 中文乱码的头号元凶,POST 请求必须靠这个过滤器 --> <filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>这里有个高频疑问:为什么要有父子两个容器?父容器(ContextLoaderListener加载的applicationContext.xml)管 service 和 dao,子容器(DispatcherServlet加载的spring-mvc.xml)管 controller。子容器能看见父容器的 Bean,反过来不行。如果@Transactional没生效,先把注解所在类启动时到底被哪个容器扫描了搞清楚——被 SpringMVC 容器扫到的 service,父容器的事务管理器是注入不进去的。
4.2 一张表排查启动失败与 500 错误
| 报错信息 | 常见原因 | 处理方式 |
|---|---|---|
Invalid bound statement (not found) | Mapper 接口与 XML 不同名,或mapperLocations路径写错 | 检查接口全限定名与 XML namespace 是否一致,XML 是否落在classpath:mapper/下 |
NoSuchBeanDefinitionException | 注解扫描包没覆盖,或父子容器扫重 | 确认context:component-scan的 base-package,service 只交给父容器扫 |
No qualifying bean of type DataSource | 数据源配置未加载,或属性占位符没生效 | 检查PropertySourcesPlaceholderConfigurer是否声明 |
url为 null 的Communications link failure | db.properties 没被读到,或 MySQL 未启动 | 打印一次dataSource.getUrl()确认 |
| 页面中文乱码 | 缺 CharacterEncodingFilter 或顺序不对 | filter-mapping 的 url-pattern 必须是/*,且要放在所有 filter 之前 |
Ambiguous mapping | 两个方法映射了同一个 URL 和方法 | 检查@RequestMapping是否重复 |
| 静态资源 404 | url-pattern为/后静态资源被拦 | 加<mvc:default-servlet-handler/> |
排查顺序建议固定下来:先看 Tomcat 启动日志里有没有Root WebApplicationContext initialized,没有就是父容器挂了;再看DispatcherServlet是否初始化成功;最后才看业务抛出的异常。跳过前两步直接盯着 500 页面看堆栈,很容易在一堆嵌套异常里找错方向。
4.3 事务不生效、库存扣成负数怎么查
事务问题是这套系统里最隐蔽的一类。按下面的顺序过一遍,基本能定位到九成场景。
第一,确认代理有没有生成。在 service 的构造方法里打印this.getClass().getName(),如果输出里带$$EnhancerBySpringCGLIB$$或$Proxy,说明被代理了;输出的是原始类名,就是配置问题。
第二,确认异常类型。@Transactional默认只回滚 RuntimeException 和 Error。自定义异常建议统一继承 RuntimeException,或者老老实实写上rollbackFor = Exception.class。
第三,确认调用路径。this.createOrder()、a()调b()这类本类自调用不经过代理,注解等于没写。真需要拆,把子逻辑放到另一个 service,或者注入自身代理。
第四,确认数据库引擎。MySQL 里把表建成 MyISAM,事务压根不起作用,SHOW TABLE STATUS看 Engine 是不是 InnoDB。
库存扣成负数则要看三处:扣减 SQL 有没有stock >= #{quantity}条件;数据库 stock 字段是不是无符号;以及有没有别的地方用UPDATE t_book SET stock = #{newStock}这种全量覆盖式更新——两个线程各算出一个值再覆盖,条件扣减的原子性就废了。把日志级别调到 DEBUG,MyBatis 会打印实际执行的 SQL 和参数,对照着看扣减语句是不是每次都用stock = stock - ?。
5. 让 SSM 书店系统经得起追问:索引、缓存与 N+1 优化
系统能跑通之后,真正拉开差距的是性能细节。图书列表页最容易出的问题是 N+1 查询:先查一页图书,再循环按category_id查分类名。
<!-- 错误示范:循环调 selectCategoryById,一页 20 本书就 21 次查询 --> <!-- 正确做法:连表一次取回,用关联映射或扁平 DTO 承接 --> <select id="selectPageWithCategory" resultType="BookVO"> SELECT b.id, b.title, b.price, b.stock, c.name AS categoryName FROM t_book b LEFT JOIN t_category c ON b.category_id = c.id WHERE b.status = 1 ORDER BY b.id DESC LIMIT #{offset}, #{pageSize} </select>BookVO是个扁平 DTO,多一个categoryName字段即可,比在 resultMap 里配 association 更省事,也避免了懒加载在事务外触发额外查询。
分类列表这种几乎不变的数据,适合放进缓存。Spring Cache 加 Redis 是最小改动方案:在CategoryService.listAll()上标@Cacheable(value = "category", key = "'all'"),在增删改方法上标@CacheEvict。要注意自调用同样不走代理,缓存注解和事务注解有一样的失效条件。
分页性能上,深分页LIMIT 100000, 20会让 MySQL 扫描大量行再丢弃。书量大的场景换成游标分页——前端带上一页最后一条的 id,SQL 改成WHERE id < #{lastId} ORDER BY id DESC LIMIT 20,配合之前建好的索引,翻到第一万页也是常数时间。
最后是验证方法:单测里用System.nanoTime()环绕查询,跑一百次取平均;SQL 层面开logImpl打印每条语句,肉眼确认没有循环里的单条查询。压测用 JMeter 起 50 个线程并发下单同一本书,结束后SELECT stock FROM t_book WHERE id = ?,只要不是负数,条件扣减就是真的生效了。
本文还有配套的精品资源,点击获取