☰
SSM整合教科书:网上书城系统从源码到部署全解析
2026/9/26 3:45:03 网站建设 项目流程

简介:基于SSM架构的网上书城系统完整源码包,面向Java Web开发学习者与课程设计人群,演示了Spring、SpringMVC、MyBatis三大框架的整合应用。项目功能覆盖用户注册登录、个人中心、图书展示与搜索、购物车管理、订单状态跟踪及在线支付等典型电商流程,代码结构清晰,注释到位,适合作为毕业设计或SSM框架实战练习的参考蓝本。压缩包共1253个文件,大小14.6MB,核心代码以120个java文件、100个jsp页面为主,辅以364个js脚本、146个css样式表及png/gif等图片素材,同时包含sql数据库脚本和xml配置文件,便于直接导入数据库并快速启动项目。目前已有43人学习,适合需要完整项目源码进行二次开发或框架学习的读者。

1. 网上书城系统,SSM整合的教科书式样本

这个基于SSM架构的网上书城系统,不是那种只跑通单表增删改查的演示品。它把用户注册登录、图书检索、购物车、订单流转、在线支付回调整个闭环都装进了一个工程里,而且用的是最传统的JSP + Servlet容器方案。拿到源码你会发现,它没有Spring Boot自动配置的魔法,所有Bean、映射器、视图解析器都是手写XML声明出来的,这反而让它的学习价值更高——你能看清楚Spring容器到底在你启动Tomcat时干了什么。对于刚学完Spring、想用实际项目把框架串起来的人,或者需要一套能快速二次开发的交易类系统原型的工程师,这份代码的参考意义都很大。它同时回答了三个问题:SSM整合的规范格式是什么?用户购书这条链路涉及哪些状态切换?老式Eclipse Web项目的目录结构长什么样。

2. SSM整合与项目骨架拆解

2.1 为什么选SSM而不是直接上Spring Boot

这个源码是典型的Eclipse Dynamic Web Project结构,没有Maven的pom.xml,依赖全部放在WEB-INF/lib下。放到今天,很多人会直接选Spring Boot,但SSM自有它的场景:一是老系统的维护和迁移需求仍然大量存在,二是SSM的显式配置能让人把依赖注入、事务传播、AOP切面这些概念看得明明白白。比如你在applicationContext.xml里看到<tx:advice>,就会直观地理解“事务是把Dao方法包进代理类”这件事。

从这个项目里你能学到一种分层惯例:Controller只做参数接收和视图转发,Service层用@Transactional控制数据库事务,Dao层写接口和XML映射。书城的核心业务——库存扣减、订单生成——正好需要这种严格的事务边界,所以SSM放在这里是合适的,不是框架在硬套业务。

2.2 项目目录与配置文件的映射关系

拿到源码后先看.classpath和org.eclipse.wst.common.component这两个文件,它们告诉你这个Web项目的上下文路径和源码目录。正式代码集中在src目录下,按com.xxx.bookstore这样的包名分成controller、service、dao、entity四层,JSP页面和静态资源在WebContent目录。

配置文件的职责分割是这样的:

配置文件管什么对应技术点
applicationContext.xml数据源、事务、Service和Dao的BeanSpring核心容器
spring-mvc.xml控制器扫描、视图解析器、静态资源映射SpringMVC家族
mybatis-config.xml别名、驼峰转换、映射文件注册MyBatis全局参数
db.properties数据库连接串、用户名密码外部化配置

这些配置文件的加载顺序决定了项目能不能启动。web.xml里会配置ContextLoaderListener加载applicationContext.xml,同时配置DispatcherServlet加载spring-mvc.xml。注意两个容器是父子关系,子容器(SpringMVC)不能扫描Service层,否则会出现事务不生效的坑。

2.2.1 数据访问层配置的关键代码

看一下spring-dao.xml(或applicationContext.xml里的对应片段)的常见写法:

<!-- 数据库连接池 --> <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <!-- SqlSessionFactory,让MyBatis知道去哪里找XML映射文件 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <property name="mapperLocations" value="classpath:com/xxx/bookstore/dao/*.xml"/> </bean> <!-- 扫描Dao接口,为每个接口生成代理对象 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.xxx.bookstore.dao"/> </bean>

这里值得展开的是mapperLocations的路径写法。常见做法是把Mapper XML和Dao接口放在同一个包下,这样扫描路径就是classpath:com/xxx/bookstore/dao/*.xml。如果XML放在resources目录,路径要改成classpath*:mapper/*.xml。这两个弄混是最常见的启动报错来源。

MapperScannerConfigurer不需要sqlSessionFactory属性,它会自动从容器里拿。但要注意它不能用@Bean方式定义在@Configuration类里,否则Spring容器初始化顺序不对时会扫描不到接口,这个坑我后面还会说。

2.3 MyBatis映射文件与实体别名

mybatis-config.xml里通常开启驼峰映射:

<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> <typeAliases> <package name="com.xxx.bookstore.entity"/> </typeAliases> </configuration>

开启驼峰映射后,数据库字段book_name自动对应实体属性bookName,省略一大串resultMap。但这里有个前提:你的SQL返回列名必须是下划线风格,如果写了select book_name as bookname,驼峰映射就失效了。看书城源码里BookDao.xml会发现,它的大部分查询都直接用了resultType="Book",靠的正是这个全局配置。

3. 用户模块与图书检索的落地写法

3.1 用户注册中的MD5加密与校验

书城项目的用户表一般有username、password、email、phone这几个字段。注册逻辑里最不能省略的就是密码处理。源码里虽然可能直接用了MD5工具类,但这里要提醒你:MD5本身并不可逆,但彩虹表攻击很容易破解弱密码。合格的做法是加盐:

public String encryptPassword(String plainPassword, String salt) { String salted = plainPassword + salt; String md5Hex = DigestUtils.md5DigestAsHex(salted.getBytes(StandardCharsets.UTF_8)); // 实际项目里再叠加一次SHA-1或使用BCrypt更好 return md5Hex; } // 注册时生成随机盐 String salt = UUID.randomUUID().toString().substring(0, 6); user.setSalt(salt); user.setPassword(encryptPassword(rawPassword, salt));

参数说明:salt是每个用户独立的随机字符串,它让相同明文密码产生不同的密文。验证登录时,先根据用户名查出用户,再用用户自己的盐去加密输入密码,比对结果。这个项目里如果只做了裸MD5,你可以顺手改掉,不影响其他模块。

3.2 登录状态管理:Session与拦截器

SSM项目最常见的登录校验是使用HandlerInterceptor而不是Servlet的Filter,原因是拦截器能拿到Handler对象,更细粒度地控制哪些URL需要登录。代码骨架如下:

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User user = (User) session.getAttribute("loginUser"); if (user == null) { // 这里要区分是否为Ajax请求 String requestedWith = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(requestedWith)) { response.setStatus(401); } else { response.sendRedirect(request.getContextPath() + "/login.jsp"); } return false; } return true; } }

然后在spring-mvc.xml里注册:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/cart/**"/> <mvc:mapping path="/order/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/register"/> <bean class="com.xxx.bookstore.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>

这里的关键是exclude-mapping。如果你把/books、/search也排除了,游客就能浏览图书,这符合网上书城的业务逻辑。注意区分/cart/**和/cart,前者匹配/cart/add、/cart/list,但匹配不到/cart本身,需要单独写一条<mvc:mapping path="/cart"/>。

3.3 图书搜索:SQL动态拼装与防注入

图书搜索一般按书名、作者、分类三个条件组合查询。源码里的BookDao.xml常见写法是:

<select id="searchBooks" resultType="Book"> SELECT * FROM book <where> <if test="keyword != null and keyword != ''"> AND (book_name LIKE CONCAT('%', #{keyword}, '%') OR author LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> ORDER BY sale_count DESC LIMIT #{offset}, #{pageSize} </select>

动态SQL的<where>标签会自动去掉第一个条件前的AND,比手动加where 1=1干净得多。#{keyword}使用PreparedStatement参数占位,不会产生SQL注入。注意这里不要写成${keyword},后者是字符串拼接,在搜索框输入' or '1'='1会炸穿全库。

分页参数offset和pageSize要从Controller接收并转换成整数再做运算,因为LIMIT不支持预编译参数直接运算,你可以在Service层先计算出offset = (pageNum - 1) * pageSize。这个项目如果是用PageHelper插件,则不需要手动算,但看源码时注意它到底用的哪种方式,会影响你修改时的思路。

4. 购物车、订单与支付状态机

4.1 购物车是存内存还是存库

书城系统网上购物车有两种实现路径:匿名用户存Session,登录用户存数据库。这个源码采用的是登录后才允许加购物车,所以购物车数据直接落库,表结构一般是:

字段类型说明
cart_idint PK主键
user_idint用户ID
book_idint图书ID
quantityint数量
checkedtinyint是否选中结算
add_timedatetime加入时间

落库的好处是换设备购物车不丢,坏处是每次进入购物车页面都要查库。如果后续要做性能优化,可以用Redis缓存用户购物车,以cart:userId为Key,使用Hash结构存储bookId -> quantity。但要注意库存扣减的时机,不能只改Redis,最终下单时还是要校验数据库中的真实库存。

4.2 订单状态流转与数据表设计

订单表是这类系统的核心。状态字段建议用tinyint类型,而不是字符串,因为数字更好比较、占空间更小。字段status的取值定义需要在代码里以常量类维护:

public class OrderStatus { public static final int WAIT_PAY = 0; // 待付款 public static final int PAID = 1; // 已付款 public static final int SHIPPED = 2; // 已发货 public static final int COMPLETED = 3; // 已完成 public static final int CANCELED = 4; // 已取消 }

生成订单的时序应该是:从购物车读取选中项 → 计算总价 → 创建订单主表记录(含订单号) → 批量插入订单明细表 → 扣减库存 → 清空购物车。整个过程必须放在一个事务里,否则会出现订单创建成功但库存没扣的问题。Service层方法的典型写法:

@Transactional(rollbackFor = Exception.class) public Order createOrder(Integer userId, Integer[] cartIds) { // 1. 查询购物车列表 List<CartItem> items = cartDao.selectByIds(cartIds); // 2. 校验库存 for (CartItem item : items) { Book book = bookDao.selectById(item.getBookId()); if (book.getStock() < item.getQuantity()) { throw new BookStockNotEnoughException("库存不足: " + book.getBookName()); } } // 3. 生成订单主记录 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalPrice(calculateTotal(items)); order.setStatus(OrderStatus.WAIT_PAY); orderDao.insert(order); // 4. 插入明细 for (CartItem item : items) { OrderDetail detail = new OrderDetail(); detail.setOrderId(order.getId()); detail.setBookId(item.getBookId()); detail.setQuantity(item.getQuantity()); detail.setPrice(bookDao.selectById(item.getBookId()).getPrice()); orderDetailDao.insert(detail); // 5. 扣库存: update book set stock = stock - #{num} where id = #{id} and stock >= #{num} int rows = bookDao.deductStock(item.getBookId(), item.getQuantity()); if (rows == 0) { throw new BookStockNotEnoughException("扣减失败"); } } // 6. 清空购物车 cartDao.deleteByIds(cartIds); return order; }

注意方法上的rollbackFor = Exception.class,默认Spring事务只回滚RuntimeException,如果业务异常是受检异常,就要用这种方式指定。扣库存的SQL用where stock >= #{num},通过更新影响行数来判断是否超卖,比先select再判断更稳妥。

4.3 在线支付的模拟回调与定时关单

源码里的在线支付大概率是接入的第三方支付demo,但真实支付回调必须处理验签和幂等。如果只是本地模拟,一般提供一个paySuccess接口供前端点击模拟支付后调用:

@RequestMapping("/pay/success") @ResponseBody public String paySuccess(String orderNo) { Order order = orderService.getByOrderNo(orderNo); if (order != null && order.getStatus() == OrderStatus.WAIT_PAY) { order.setStatus(OrderStatus.PAID); order.setPayTime(new Date()); orderDao.updateStatus(order); } return "success"; }

关键点在于只更新状态为“待付款”的订单,防止重复回调导致订单状态被反向覆盖。真正对接微信或支付宝回调时,还需要验证签名参数,你拿到的是用RSA签名的内容,必须用平台公钥验签,不能直接信任请求参数里的orderNo。

更完整的状态机体验还会有一个定时任务去扫描超过30分钟未付款的订单并自动取消、恢复库存。SSM项目里可以用Spring Task:在applicationContext.xml里启用<task:annotation-driven/>,然后在方法上标注@Scheduled(cron = "0 */5 * * * *")来轮询。

5. 从Eclipse到Tomcat的部署与常见坑

5.1 那些.bak文件是做什么的

下载解压后你会看到styles.css.bak、index.jsp.bak、setMenu.js.bak、topNav.jsp.bak这类文件。它们是上一任开发者修改文件前的备份,意思是“我改动了这些文件,但怕改坏了留个底”。对你来说,它们没有运行时作用,Tomcat不会解析.bak文件的扩展名,我们直接把这种后缀的文件删除或者忽略即可。不过它们也有点情报价值:从topNav.jsp.bak能看出前端导航栏可能被重写过,index.jsp.bak说明首页经历过定制化修改。

5.2 部署时最容易炸的三个点

我前两次部署这类老工程时踩过几个坑,列出来给你排查:

症状原因处理方式
java.lang.ClassNotFoundException: org.springframework.web.servlet.DispatcherServletlib里缺少Spring Web的jar包把spring-webmvc和spring-web的jar放进WEB-INF/lib
Failed to configure a DataSourcedb.properties路径不对或中文乱码确认*.properties文件编码为UTF-8,检查classpath:前缀
Invalid bound statement (not found): com.xxx.dao.UserDao.findByNameMapper XML的namespace写错或没被扫描打开XML里的namespace,必须与Dao接口全限定名一致

最容易忽略的是JDK编译级别。Eclipse导出的项目默认可能是1.5,而Tomcat 9要求Java 8+,导入IDE后第一件事是把项目的Java Compiler改为1.8,同时把Tomcat运行时依赖加入Build Path。

5.3 快速定位和验证

部署不需要完整的流程,你只需要先在本地验证两件事:第一,启动Tomcat后能打开首页;第二,能注册一个新用户并登录。把这两条链路走通了,购物车和订单模块基本不会有大问题。

注册接口返回500时,看Tomcat catalina日志里的异常栈。如果是org.mybatis.spring.MyBatisSystemException,优先检查SQL语句里的表名和字段名,老项目的数据库字段经常用desc这种关键词,需要加反引号。登录不进去时,先看控制台是否打印了SQL,没打印说明Mapper接口没注入成功,回spring-mvc.xml检查<context:component-scan>的base-package是否包含Controller目录且没有覆盖到Service实现。

支付回调测不通时,使用Postman直接往/pay/success发GET请求,看订单状态是否更新。能更新就说明代码逻辑没问题,问题在网络环境或回调配置上。这个项目里PaymentUtil如果用的是支付宝沙箱,请确认app_id和支付宝公钥是配对的——很多同学在沙箱环境下复制错公钥,验签总是失败。

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

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

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

立即咨询