简介:《基于SSM的网上书店系统设计与实现》是一份面向高校计算机相关专业学生与Java Web初学者、可作为课程设计或毕业设计参考的完整技术文档。资源包内为1个docx格式文档,体积约734KB,集中承载系统设计说明、功能模块梳理与实现要点,便于直接阅读和二次整理。目前已有48人学习下载。文档以SSM(Spring、SpringMVC、MyBatis)框架为主线,结合MyEclipse开发环境与MySQL数据库,详细阐述管理员与普通用户双平台的设计思路,涵盖用户登录注销、商品浏览与搜索、在线购买、订单管理、商品类别与信息管理等功能;同时涉及在线支付、订单跟踪和用户反馈等扩展模块。内容预览显示其包含中英文摘要、目录及绪论等章节,结构完整,可帮助读者快速理解网上书店系统的业务闭环、数据库选型与分层开发方法,适合需要撰写论文或搭建同类电商项目的读者参考。
1. 一套 SSM 网上书店,难的不是框架整合而是账目对得上
很多人拿到「基于 ssm 的网上书店系统设计与实现」这个题目,第一反应是把 Spring、SpringMVC、MyBatis 的配置文件凑齐,跑出一个能登录、能加购、能下单的页面就算收工。真做过电商类业务的人清楚,ssm 框架整合只是入场券,麻烦集中在三处:库存扣减和订单落库必须在同一个事务里,购物车在登录前后要能合并,订单金额只能由服务端重算而不能信前端传过来的数字。这套东西适合两类人:拿它做课程设计、需要在答辩时讲清分层职责的学生,以及刚转 Java Web、想借一个业务闭环不复杂的项目吃透 SSM 装配关系的开发者。下面按库表与 MyBatis 映射、分层与事务边界、检索与登录态、一致性验证四段推进,每段都给能直接抄的代码和参数。
2. 网上书店的库表设计与 MyBatis 映射落地
2.1 图书、分类、购物车、订单四组核心表怎么切
常见的新手做法是建一张大表,把图书信息、下单价格、购买数量塞在一起,写到查询时才发现同一个 book_id 既要取图书表的现价,又要在订单里保留成交价,两边改写互相覆盖。合理的切法按「资料」和「交易」两条线分开:图书、分类属于资料,会被后台随时改价改库存;购物车、订单属于交易,一旦生成就必须冻结当时的快照。判断标准很简单——这张表的数据允许被 UPDATE 覆盖,还是只能追加,前者归资料,后者归交易。
金额字段一律用DECIMAL(10,2),不要图省事用FLOAT。浮点数在 0.1 这类值上无法精确表示,几十条明细累加后总价就可能出现 0.00000001 的偏差,对账脚本一跑全红。订单状态用TINYINT存,Java 侧用枚举映射,别直接存中文状态串,否则改文案就得更新一批历史数据。
-- 图书表:资料类,允许改价改库存 CREATE TABLE `book` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `isbn` VARCHAR(20) NOT NULL COMMENT '图书唯一编码', `title` VARCHAR(200) NOT NULL, `category_id` INT NOT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (`id`), UNIQUE KEY `uk_isbn` (`isbn`), KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单主表:交易类,只追加 CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号,对外暴露', `user_id` BIGINT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待付款 1已付款 2已发货 3已完成 4已取消', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细:书名和单价都存快照,后台改价不影响历史订单 CREATE TABLE `order_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `book_id` BIGINT NOT NULL, `book_title` VARCHAR(200) NOT NULL COMMENT '下单时书名快照', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', `quantity` INT NOT NULL, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;主键为什么用自增 BIGINT 而不是 ISBN?因为 ISBN 会被业务方修正、会被回收,一旦作为主键被 order_item 引用,改一次就要级联更新。对外展示用 order_no,内部关联用自增 id,这条分离在后期加索引和分库时省事很多。
| 表名 | 职责 | 关键索引 | 易踩的坑 |
|---|---|---|---|
| book | 图书资料与库存 | uk_isbn、idx_category_status | 库存字段用无符号整型导致扣成负数不报错 |
| cart_item | 未登录/已登录购物车 | uk_user_book、idx_cart_key | 只按 user_id 建索引,合并时全表扫 |
| orders | 订单头 | uk_order_no、idx_user_status | order_no 无唯一索引,重复提交出双单 |
| order_item | 订单行快照 | idx_order_id | 存 book_id 外键却不存快照,改价毁历史 |
2.2 BookMapper 接口与 XML 的分工
MyBatis 的写法是接口只声明方法签名,SQL 落在 XML 里,两者靠 namespace 和方法名绑定。参数超过一个时必须加@Param,否则 XML 里只能按param1、param2这种位置名引用,改一个参数顺序就全乱。
public interface BookMapper { // 条件检索,注意分页由 PageHelper 拦截,不在 SQL 里写 LIMIT List<Book> selectByCondition(@Param("keyword") String keyword, @Param("categoryId") Integer categoryId); int countByCondition(@Param("keyword") String keyword, @Param("categoryId") Integer categoryId); // 条件更新扣库存,返回影响行数 int deductStock(@Param("bookId") Long bookId, @Param("quantity") int quantity); }<select id="selectByCondition" resultType="com.bookstore.entity.Book"> SELECT id, isbn, title, price, stock, category_id FROM book <where> status = 1 <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR isbn = #{keyword}) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> ORDER BY id DESC </select> <update id="deductStock"> UPDATE book SET stock = stock - #{quantity} WHERE id = #{bookId} AND stock >= #{quantity} </update><where>标签会自动吃掉开头多余的 AND,比手写WHERE 1=1干净。#{}走的是 PreparedStatement 占位符,会把参数当值处理;${}是字符串直接拼接,用在ORDER BY ${column}这种位置就等于把注入口开给了前端,检索条件里一律用#{}。
deductStock这条 UPDATE 是整章最值钱的一句:把「库存够不够」的判断写进 WHERE 里,由数据库在行锁内完成比较和扣减。应用层不用先 SELECT 再判断,也就不存在两个线程同时读到库存 1、各扣一次的问题。返回值为 0 就代表库存不足或商品不存在,调用方据此抛异常。
2.3 订单主从表的一对多 resultMap
订单详情页需要一次查出订单头和所有明细行,用<collection>做嵌套映射比在 Java 里循环查 N 次省事。这里有个硬性要求:主表的主键列必须用<id>标签而不是<result>,否则 MyBatis 无法识别哪些行属于同一个订单,会把三条明细合并成一条。
<resultMap id="OrderDetailMap" type="com.bookstore.entity.Order"> <id column="order_id" property="id"/> <result column="order_no" property="orderNo"/> <result column="total_amount" property="totalAmount"/> <result column="status" property="status"/> <collection property="items" ofType="com.bookstore.entity.OrderItem"> <id column="item_id" property="id"/> <result column="book_id" property="bookId"/> <result column="book_title" property="bookTitle"/> <result column="price" property="price"/> <result column="quantity" property="quantity"/> </collection> </resultMap> <select id="selectOrderWithItems" resultMap="OrderDetailMap"> SELECT o.id AS order_id, o.order_no, o.total_amount, o.status, i.id AS item_id, i.book_id, i.book_title, i.price, i.quantity FROM orders o LEFT JOIN order_item i ON i.order_id = o.id WHERE o.order_no = #{orderNo} </select>别名必须和 resultMap 里的 column 对齐,主从表都有id、price这类同名列,不加别名会互相覆盖成随机值。在mybatis-config里打开mapUnderscoreToCamelCase可以让order_no自动映射到orderNo,但那只解决下划线转驼峰,解决不了同名列冲突,别名该加还得加。
3. Spring 与 SpringMVC 分层:事务边界和库存扣减放在哪一层
3.1 包结构与 SqlSessionFactory 的关键配置
包结构按职责切,别按技术切得七零八落:
com.bookstore ├── controller // 只做参数接收、登录校验、结果包装 ├── service // 事务边界,业务规则 │ └── impl ├── mapper // MyBatis 接口 ├── entity / dto // 实体与传输对象分开 ├── interceptor // 登录拦截 └── common // 统一返回体、异常、常量Service 接口和实现分开,是为了后面换实现或者加 AOP 代理时不动调用方。Controller 里不写业务判断,尤其是「库存够不够」这种,放进去就没法复用,事务也管不到。
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="typeAliasesPackage" value="com.bookstore.entity"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <!-- 下划线列名自动转驼峰属性 --> <property name="mapUnderscoreToCamelCase" value="true"/> <!-- 二级缓存默认关掉,订单类数据不适合跨会话缓存 --> <property name="cacheEnabled" value="false"/> </bean> </property> <property name="plugins"> <array> <bean class="com.github.pagehelper.PageInterceptor"> <property name="properties"> <value> helperDialect=mysql reasonable=true </value> </property> </bean> </array> </property> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.bookstore.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>mapperLocations用通配符加载,新增 XML 不用改配置。MapperScannerConfigurer会为每个接口生成代理实现,所以 Service 里直接@Autowired注入 Mapper 接口即可。cacheEnabled=false是刻意关的,图书价格和库存变动频繁,二级缓存一旦命中就会读到旧库存,调试时极难发现。
3.2 结算接口:Controller 只收参数,事务压在 Service
Controller 的职责就三件:接 JSON、校验参数、拿 session 里的用户。事务必须完整包住「读购物车 → 逐本扣库存 → 写订单头 → 写订单明细」这一串。
@RestController @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/settle") public Result<String> settle(@RequestBody @Valid OrderSettleDTO dto, HttpSession session) { User user = (User) session.getAttribute("LOGIN_USER"); if (user == null) { return Result.fail(401, "请先登录"); } // 返回订单号,具体计算全部下沉到 Service return Result.ok(orderService.settle(user.getId(), dto)); } }public class OrderSettleDTO { @NotBlank(message = "收件人不能为空") private String receiver; @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") private String phone; @NotEmpty(message = "收货地址不能为空") private String address; @NotEmpty(message = "请选择要结算的商品") private List<Long> cartItemIds; // 只收购物车行 id,不收数量和价格 }注意 DTO 里没有totalAmount,也没有每本书的quantity。前端传来的金额一律不信,数量从数据库里的购物车行读。这是防篡改的第一道闸。
@Service public class OrderServiceImpl implements OrderService { @Autowired private BookMapper bookMapper; @Autowired private CartMapper cartMapper; @Autowired private OrderMapper orderMapper; @Override @Transactional(rollbackFor = Exception.class) public String settle(Long userId, OrderSettleDTO dto) { List<CartItem> items = cartMapper.selectByIds(userId, dto.getCartItemIds()); if (items.isEmpty()) { throw new BizException("购物车为空"); } BigDecimal total = BigDecimal.ZERO; for (CartItem item : items) { // 条件更新,返回 0 即库存不足,直接回滚整单 int rows = bookMapper.deductStock(item.getBookId(), item.getQuantity()); if (rows == 0) { throw new BizException("《" + item.getBookTitle() + "》库存不足"); } total = total.add(item.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); } Order order = new Order(); order.setOrderNo(OrderNoGenerator.next(userId)); order.setUserId(userId); order.setTotalAmount(total); // 服务端重算的金额 order.setStatus(0); orderMapper.insert(order); orderMapper.insertItems(order.getId(), items); cartMapper.deleteByIds(userId, dto.getCartItemIds()); return order.getOrderNo(); } }rollbackFor = Exception.class不是可选项。Spring 默认只在遇到RuntimeException和Error时回滚,而SQLException这类受检异常不会触发回滚,会出现「扣了库存但订单没写进去」的脏数据。业务异常继承RuntimeException也行,但加上这行更保险。
insertItems用<foreach>批量插入,别在循环里逐条 insert,100 条明细就是 100 次网络往返。
3.3 @Transactional 失效的四种真实场景
| 场景 | 现象 | 处理方式 |
|---|---|---|
| 同类内部方法自调用 | 被调方法上的注解完全不生效 | 把方法挪到另一个 Bean,或注入自身代理 |
| 方法不是 public | 代理无法拦截,静默失效 | 改成 public |
| 异常被 try-catch 吃掉 | 事务不回滚,数据半更新 | catch 后重新抛出,或手动setRollbackOnly |
| 事务内做远程调用 | 数据库连接被长时间占用 | 远程调用挪到事务外 |
自调用这条最容易中招:settle里直接this.doInsert(),doInsert上就算标了@Transactional(propagation = REQUIRES_NEW)也无效,因为调用根本没经过 Spring 生成的代理对象。判断方法很简单,在方法里打个断点看调用栈,栈顶是不是$Proxy开头的类,不是就说明代理没生效。
4. 图书检索、分页与登录态:交互层最容易翻车的三个点
4.1 关键词检索与 PageHelper 的参数含义
图书列表是这个系统的流量入口,检索慢会拖垮整个首页。PageHelper 的用法有一条铁律:startPage后面紧跟的那一条查询才会被拦截并自动改写,中间插任何别的查询都会导致分页失效甚至线程串页。
public PageInfo<BookVO> search(BookQuery query) { // 紧跟其后的第一条 MyBatis 查询才会被分页 PageHelper.startPage(query.getPageNum(), query.getPageSize()); List<BookVO> list = bookMapper.selectByCondition( query.getKeyword(), query.getCategoryId()); PageInfo<BookVO> page = new PageInfo<>(list); // 手动覆盖总数字段,绕过 count 查询重复执行的坑 page.setTotal(bookMapper.countByCondition( query.getKeyword(), query.getCategoryId())); return page; }| 参数 | 取值 | 作用与副作用 |
|---|---|---|
| helperDialect | mysql | 决定生成哪种方言的 LIMIT,配错直接语法报错 |
| reasonable | true | 页码越界时自动回正到第一页或最后一页,关掉会返回空列表 |
| supportMethodsArguments | true | 允许从方法参数自动取 pageNum,方便但会隐藏调用链 |
reasonable=true在对外接口上建议开着,用户手动把 URL 里的pageNum改成 9999 时返回最后一页而不是空数据;但如果前端分页组件依赖「越界返回空」来做边界提示,就得关掉。
关键词用LIKE '%关键词%'时索引必然失效,因为左侧通配符让 B+ 树无法定位起点。数据量在几万条以内感知不明显,上了十万条就得换思路:要么改成前缀匹配让title上的索引生效,要么单独抽一张搜索表或者上全文检索。别在图书主表上堆各种组合索引,写入和回滚的代价会反过来拖慢下单。
4.2 登录拦截器与购物车合并
未登录时加购只能存在 cookie 或 session 里的临时标识,登录后要把这些行合并到用户购物车。拦截器负责挡住未登录的写操作。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { HttpSession session = req.getSession(false); Object user = (session == null) ? null : session.getAttribute("LOGIN_USER"); if (user == null) { resp.setStatus(401); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"msg\":\"请先登录\"}"); return false; } return true; } }<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/api/**"/> <!-- 图书浏览和登录接口放行 --> <mvc:exclude-mapping path="/api/book/**"/> <mvc:exclude-mapping path="/api/user/login"/> <mvc:exclude-mapping path="/api/user/register"/> <bean class="com.bookstore.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>合并没有必要在 Java 里 for 循环判断,交给一条 SQL 更快,前提是cart_item上有(user_id, book_id)唯一索引:
-- 同一本书直接叠数量,不产生重复行 INSERT INTO cart_item (user_id, book_id, quantity, create_time) SELECT #{userId}, c.book_id, SUM(c.quantity), NOW() FROM cart_item c WHERE c.cart_key = #{cartKey} GROUP BY c.book_id ON DUPLICATE KEY UPDATE quantity = cart_item.quantity + VALUES(quantity);合并完成后记得清掉临时cart_key对应的行,否则用户下次退出登录再进来,会看到合并前的旧数据。唯一索引这件事必须提前做,上线后再加,历史数据里的重复行会让 ALTER 直接失败。
4.3 慢 SQL 定位与索引调整
# 开启慢查询日志,阈值设 1 秒 SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; # 看某条检索语句的执行计划 EXPLAIN SELECT id, title, price FROM book WHERE title LIKE '%设计模式%' AND status = 1 ORDER BY id DESC;| 现象 | 根因 | 处理 |
|---|---|---|
| 图书检索 type=ALL、rows 百万级 | 左侧通配符导致 title 索引失效 | 改前缀匹配,或缩小 status 过滤后的结果集 |
| 订单列表响应 2 秒以上 | 只有 idx_user_status 却按 create_time 排序 | 建(user_id, status, create_time)联合索引 |
| 购物车合并报死锁 | 无唯一索引导致 gap 锁范围过大 | 补(user_id, book_id)唯一索引 |
| count 查询比列表还慢 | 每次翻页都重新 count 全表 | 缓存总数,或超过阈值时用近似值 |
EXPLAIN里的Extra出现Using filesort说明排序没走索引,Using temporary说明用到了临时表,这两个在订单列表接口上出现,基本就是联合索引的字段顺序排反了——等值条件字段放前面,范围或排序字段放最后。
5. 对账脚本与并发下单:验证 SSM 网上书店的数据一致性
功能能跑通不等于数据是对的。验证这类系统,光靠手点页面看不出超卖,得用并发压测和事后对账两条腿走。
先看条件更新在并发下的实际表现。用 JUnit 起 50 个线程同时抢同一本书的 1 本库存,观察成功计数:
@Test public void testConcurrentDeduct() throws Exception { final int threads = 50; ExecutorService pool = Executors.newFixedThreadPool(threads); CountDownLatch ready = new CountDownLatch(threads); CountDownLatch go = new CountDownLatch(1); AtomicInteger success = new AtomicInteger(0); for (int i = 0; i < threads; i++) { pool.submit(() -> { try { ready.countDown(); go.await(); // 所有线程同时开跑 int rows = bookMapper.deductStock(1L, 1); if (rows > 0) success.incrementAndGet(); } catch (Exception ignored) { } }); } ready.await(); go.countDown(); pool.shutdown(); pool.awaitTermination(30, TimeUnit.SECONDS); // 库存为 1 时,正确结果必须是 1 System.out.println("扣减成功次数:" + success.get()); }输出如果是 1,说明 WHERE 里的stock >= #{quantity}起到了原子比较的作用;如果是 3 或者 5,通常有两种原因:一是数据库隔离级别被设成了 READ UNCOMMITTED,二是把判断挪到 Java 里先 SELECT 再 UPDATE 了。这个用例应该进 CI,每次改完库存相关代码都跑一遍。
再看对账。下单流程涉及图书表的 stock 和 order_item 里的 quantity 两个地方,一旦事务回滚不彻底,两者就会对不上:
-- 库存 + 已售(排除已取消订单)应当等于入库总量 SELECT b.id, b.title, b.stock, IFNULL(SUM(oi.quantity), 0) AS sold, b.stock + IFNULL(SUM(oi.quantity), 0) AS current_total FROM book b LEFT JOIN order_item oi ON oi.book_id = b.id LEFT JOIN orders o ON o.id = oi.order_id AND o.status IN (0, 1, 2, 3) -- 4 为已取消,库存已归还,必须排除 WHERE b.id = 1 GROUP BY b.id, b.title, b.stock;把这条 SQL 挂到定时任务里每天凌晨跑一次,current_total和入库总量不一致的行全部落表告警。取消订单一定要在同一个事务里把库存加回去并把状态改成 4,漏掉任何一步,对账结果都会持续漂移。
订单号生成也别用System.currentTimeMillis()单独拼,高并发下同一毫秒会撞号,而uk_order_no唯一索引一旦报冲突,用户看到的就是一个 500。稳妥的做法是时间戳加用户 id 后四位再加一段随机数,或者用数据库自增段表统一发号。
最后一个容易被忽略的点:order_item里存的price和book_title是快照,后台改价改书名后历史订单必须保持原样。如果有运营提需求说「订单列表要显示图书最新封面」,那就用book_id关联查最新数据,但金额和书名永远读快照字段——对账 SQL 里的SUM(oi.quantity)之所以能算准,正是因为明细行不会被任何后台操作改写。
本文还有配套的精品资源,点击获取