ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SSM框架实战:蛋糕商城系统的架构设计与核心实现

SSM框架实战:蛋糕商城系统的架构设计与核心实现 简介一套基于SSMSpring、SpringMVC、MyBatis框架的蛋糕商城系统完整项目专为计算机专业毕业设计、期末大作业及Java后端进阶学习者打造可用于掌握SSM整合开发与电商业务实现。资源压缩包内含2000个文件除122个Java源码文件外还包含JSP页面、SQL数据库脚本、docx/doc论文与开发文档以及JS、CSS、图片等前端素材整体容量178.57MB目录划分清楚便于按源码、文档、数据库逐项查阅。当前已有54人学习下载适合希望对照完整项目进行实战演练的读者。项目经导师指导并获高分认可源码本地编译可运行附带数据库设计文档与详细说明可辅助理解商品展示、购物车、订单处理、用户登录等模块的实现逻辑同时为论文撰写和答辩提供参考资料是一份可直接用于课设或毕设的优质模板。1. 用SSM框架拆一个能跑的蛋糕商城关键不在代码量很多人在期末大作业或者毕业设计里选了电商类系统最后交上去的却是一个“能点按钮但经不起问”的演示品。这套基于SSM的蛋糕商城系统是少有的把Spring、SpringMVC、MyBatis三者协作讲清楚、同时还能直接运行的项目。它覆盖了商品展示、用户登录、购物车、订单处理这些电商系统最核心的闭环难度适中既能让初学者跟着代码理解三层架构的分工也能让有经验的开发者看到一些值得抠的细节——比如订单状态怎么流转、购物车数据放Session还是放DB、事务边界划在哪。适合谁正在做Java Web方向期末大作业或毕业设计的学生以及想从“看教程”过渡到“读完整项目源码”的人。下面我从工程结构、数据持久化、请求链路、状态机设计到最后的部署验证把这个系统的设计逻辑完整拆一遍。2. SSM三框架协作看工程结构就知道系统怎么跑起来2.1 三框架的边界与协作关系先建立一个整体认知。在SSM架构里Spring是容器负责管理Service层对象以及事务SpringMVC是Web层框架负责接收HTTP请求、调用Service、返回视图或JSONMyBatis是持久层框架负责把Java对象映射成SQL参数、把结果集映射回JavaBean。三者通过配置文件串联起来工程启动时由Spring容器统一加载。我们来看这套蛋糕商城系统的配置文件分类拆开资源包后你会看到经典的分层结构。src/main/java ├── com.cake.shop.controller // SpringMVC控制器 ├── com.cake.shop.service // Service接口和实现事务加在这里 ├── com.cake.shop.mapper // MyBatis的Mapper接口 ├── com.cake.shop.entity // 实体类对应数据库表 └── com.cake.shop.utils // 工具类比如分页、价格格式化 src/main/resources ├── spring-context.xml // 根容器数据源、Service、事务 ├── spring-mvc.xml // Web容器控制器扫描、视图解析器 └── mapper // SQL映射XML和Mapper接口对应这里我一般会提醒第一次看SSM项目的人spring-context.xml管的是Service那一层spring-mvc.xml只管Controller这一层。业务方法上的Transactional注解能在实际运行时生效是因为项目在spring-context.xml里配置了tx:annotation-driven事务管理器接管了Service层的代理对象。若事务失效先检查这个配置是否被注释掉这是最常见的坑。2.2 从web.xml看请求如何被接管web.xml是Web应用的入口里面有两点值得展开。第一点是ContextLoaderListener加载spring-context.xml第二点是DispatcherServlet加载spring-mvc.xml。这里有个容易混淆的细节Spring容器和SpringMVC容器是父子容器关系子容器能看到父容器的Bean反过来不行。所以Controller能注入Service但Service不能反注入Controller。在源码里按下述方式排查调试即可验证这套关系是否生效。!-- web.xml 核心片段 -- listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servletinit-param指定子容器的配置文件位置load-on-startup设为1表示应用启动时立即初始化。调试时注意Tomcat的启动日志里出现了Root WebApplicationContext和DispatcherServlet两行初始化信息就说明父子容器都正常加载了。3. MyBatis数据持久层商品查询与数据库设计文档怎么配合看3.1 数据库表结构与实体类映射蛋糕商城系统涉及的核心表有用户表、商品表、分类表、购物车表、订单表、订单明细表数据库文档里给出的是MySQL建表脚本和ER图。我建议你按这样的顺序去读文档先看订单明细表和订单表怎么关联再看购物车表为什么不冗余商品价格最后看用户表索引设计。这里列一下最关键的订单相关表结构这是理解前面几章仓储逻辑的数据基础。CREATE TABLE tb_order ( id INT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号业务唯一, user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消, create_time DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_order_item ( id INT NOT NULL AUTO_INCREMENT, order_id INT NOT NULL, cake_id INT NOT NULL, cake_name VARCHAR(64) NOT NULL COMMENT 快照冗余商品名, price DECIMAL(10,2) NOT NULL COMMENT 快照下单时价格, quantity INT NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意订单明细表里的cake_name和price两个字段这是刻意为之的设计。下单时把商品名称和价格冗余到明细表之后商品库里的价格怎么改都不影响历史订单的金额核算。实体类里对应Order和OrderItem两个JavaBean属性与字段按驼峰命名映射mybatis-config里配置了map-underscore-to-camel-casetrue所以order_no可以直接映射到orderNo省去手写resultMap的额外代码。3.2 商品列表的分页SQL和动态条件查询商品展示模块是首页的核心查询场景系统里用MyBatis动态SQL实现了分类筛选和价格范围过滤。参数怎么传、SQL怎么拼不妨看下面这个映射文件的写法。!-- ProductMapper.xml -- select idselectProductPage resultTypecom.cake.shop.entity.Product SELECT id, name, price, image_url, category_id, sales FROM tb_product where if testcategoryId ! null AND category_id #{categoryId} /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY choose when testsort salessales DESC/when when testsort price_ascprice ASC/when otherwisecreate_time DESC/otherwise /choose LIMIT #{offset}, #{pageSize} /selectwhere标签会自动去掉第一个多余的AND替代了手写WHERE 11的写法这是MyBatis里很实用的细节。choose实现了排序字段的白名单控制避免外部参数直接拼进ORDER BY造成SQL注入。看着这段代码你还会发现LIMIT用了offset, pageSize的写法不用担心分页插件PageHelper在项目里做了拦截器层面的封装业务代码里只需要先调用PageHelper.startPage(pageNum, pageSize)再执行查询即可拿到分页数据。3.3 调试持久层日志级别与SQL输出看这个系统的SQL是否正确执行我一般会在排查阶段把MyBatis的日志级别开到DEBUG配合映射文件里的parameterType检查参数绑定情况。# logback.xml 或 log4j.properties 里追加 log4j.logger.com.cake.shop.mapperDEBUG log4j.logger.java.sql.ConnectionDEBUG log4j.logger.java.sql.PreparedStatementDEBUG把com.cake.shop.mapper这个包名的日志级别调成DEBUG后控制台会输出Mapper接口对应XML里执行的完整SQL语句以及预编译参数。注意观察PreparedStatement日志里后面的参数值如果出现null说明传入的条件参数没有被正确绑定这个时候回头检查Service层传给Mapper的方法入参大部分问题出在Map的key和XML里#{xxx}的名称对不上。至于PreparedStatement本身的预编译机制它是解决SQL注入的根本方式——参数与SQL语句分开编译语法结构在参数进入前就已经确定这也是为什么所有参数绑定都用#{}而不是${}。4. SpringMVC请求链路与购物车状态管理实战4.1 从Controller到Service的调用约定这套蛋糕商城的Controller层走的是经典REST风格加视图返回的混合模式。页面跳转用ModelAndViewAjax数据交互用ResponseBody返回JSON。先看用户加入购物车的接口实现这是整个购买流程里最典型的写操作。Controller RequestMapping(/cart) public class CartController { Autowired private CartService cartService; PostMapping(/add) ResponseBody public Result addCart(RequestParam Integer productId, RequestParam(defaultValue 1) Integer quantity, HttpSession session) { User user (User) session.getAttribute(loginUser); if (user null) { return Result.error(请先登录); } cartService.addToCart(user.getId(), productId, quantity); return Result.success(已加入购物车); } }RequestParam里的defaultValue属性很关键它让前端少传参数时系统不至于直接抛异常。Result对象是统一响应体包含code、message、data三个字段前端拿到code200才做后续跳转。这种封装方式在单体Web项目里非常实用。Service层的实现要说明的是系统将购物车数据存入了tb_cart表而非Session。原因有二第一用户在不同设备上登录购物车不丢失第二后台可以分析加购数据做商品推荐。代价是每次操作购物车都多一次DB访问但在这个体量的系统里完全可接受。Override Transactional public void addToCart(Integer userId, Integer productId, Integer quantity) { Cart cart cartMapper.selectByUserIdAndProductId(userId, productId); if (cart null) { Cart newCart new Cart(); newCart.setUserId(userId); newCart.setProductId(productId); newCart.setQuantity(quantity); cartMapper.insert(newCart); } else { cart.setQuantity(cart.getQuantity() quantity); cartMapper.updateById(cart); } }这段addToCart的业务逻辑需要三个映射方法配合查、插、改。先走查询命中则加数量未命中则新建记录。Transactional确保判断和更新在同一个事务内完成避免并发请求下出现数据不一致。4.2 拦截器与登录态校验购物车、订单这类写接口必须有登录校验。系统用的是SpringMVC的HandlerInterceptor拦截器机制在preHandle方法里检查Session中的用户对象。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); if (session.getAttribute(loginUser) null) { // 判断是否Ajax请求 String xhr request.getHeader(X-Requested-With); if (XMLHttpRequest.equals(xhr)) { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录\}); } else { response.sendRedirect(request.getContextPath() /login); } return false; } return true; } }拦截器里的Ajax判断是一个值得借鉴的设计页面跳转请求直接重定向到登录页异步请求返回JSON状态码让前端自行处理两套交互模式互不干扰。在spring-mvc.xml里注册这个拦截器时exclude-mappings需要排除登录接口和商品展示接口否则会出现死循环。代码断点排查时preHandle返回false则请求终止生成响应这条链路清晰且可预期。4.3 购物车结算与库存扣减的配合从购物车跳转到结算页前端传过来的是勾选的购物车条目ID列表后端把这些ID对应的商品信息组装成订单确认页数据。这里有一个关键点结算页的价格必须从数据库实时查询不能用购物车里冗余的价格字段。购物车表只在加购时写入当时价格若管理员调价购物车里的价格就是过期的查询商品表能取到最新价格但要告知用户价格有变动此时需要前端把实时价格与购物车展示价格做对比。下面是用MyBatis批量查询购物车关联商品信息的Mapper实现。select idselectCartItemsWithProduct resultMapCartWithProductMap SELECT c.id AS cart_id, c.quantity, c.product_id, p.name AS product_name, p.price AS product_price, p.stock FROM tb_cart c INNER JOIN tb_product p ON c.product_id p.id WHERE c.id IN foreach collectioncartIds itemid open( separator, close) #{id} /foreach AND c.user_id #{userId} /selectforeach标签生成IN子句时用open、separator、close控制左右括号和逗号。这条SQL把购物车表与商品表做了一次内关联取出当前实时价格和库存量。注意第一个条件参数cartIds是用户从页面上勾选的条目第二个条件参数userId绑定的是登录后的用户ID两个条件同时生效能防止用户越权操作他人的购物车条目——提交结算前前端只传了cartIds后端必须从Session里取用户ID做二次校验。结合第3.1节独立的订单明细表结构整个购买链路从查询商品、加购、结算到生成订单的时序关系就完整了。5. 订单状态机与事务边界防止超卖和重复提交5.1 状态字段约定与流转规则订单是电商系统里状态最复杂的模块。蛋糕商城的订单状态用TINYINT类型存储避免了字符串对不上导致的分支判断Bug。下表是系统文档里映射的状态常量定义。status值状态含义触发动作下一个合法状态0待支付用户提交订单1或4取消1已支付支付回调/模拟支付2发货2已发货管理员操作3完成3已完成用户确认收货无4已取消用户取消/超时无状态流转不是随心所欲跳转的Service层校验合法流转的方式是先从DB查询当前状态再比对目标状态是否在合法路径表中。这里提供一个通用的状态校验片段和订单表结构结合起来看时会更容易理解它的防御价值。private static final MapInteger, ListInteger STATUS_TRANSITIONS new HashMap(); static { STATUS_TRANSITIONS.put(0, Arrays.asList(1, 4)); STATUS_TRANSITIONS.put(1, Arrays.asList(2, 4)); STATUS_TRANSITIONS.put(2, Arrays.asList(3)); STATUS_TRANSITIONS.put(3, Arrays.asList()); STATUS_TRANSITIONS.put(4, Arrays.asList()); } public void updateOrderStatus(Integer orderId, Integer targetStatus) { Order order orderMapper.selectById(orderId); if (order null) { throw new BusinessException(订单不存在); } ListInteger allowed STATUS_TRANSITIONS.get(order.getStatus()); if (allowed null || !allowed.contains(targetStatus)) { throw new BusinessException(非法状态流转: order.getStatus() - targetStatus); } orderMapper.updateStatus(orderId, targetStatus); }这种基于状态对照表的写法比散落的if-else判断更清晰。看过数据库文档你会发现表结构里还有个cancel_reason字段在状态流转到4时可以保存用户取消的原因此外状态从0流转到1时需要用UPDATE ... WHERE status 0配合受影响行数来防止重复支付回调导致的幂等性问题。5.2 库存扣减与事务边界创建订单时系统要同时完成三件事生成订单主表数据生成订单明细扣减商品库存。这三步必须在一个事务里完成否则会出现订单生成了库存却没扣的严重问题。Transactional(rollbackFor Exception.class) public Order createOrder(Integer userId, ListInteger cartIds, String address) { ListCartWithProduct items cartMapper.selectCartItemsWithProduct(userId, cartIds); // 1. 计算总价并校验库存 BigDecimal total BigDecimal.ZERO; for (CartWithProduct item : items) { if (item.getStock() item.getQuantity()) { throw new BusinessException(item.getProductName() 库存不足); } total total.add(item.getProductPrice().multiply( new BigDecimal(item.getQuantity()))); } // 2. 插入订单主表 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalPrice(total); order.setStatus(0); orderMapper.insert(order); // 3. 插入订单明细 for (CartWithProduct item : items) { OrderItem orderItem new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setCakeId(item.getProductId()); orderItem.setCakeName(item.getProductName()); orderItem.setPrice(item.getProductPrice()); orderItem.setQuantity(item.getQuantity()); orderItemMapper.insert(orderItem); // 4. 扣减库存 int rows productMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows 0) { throw new BusinessException(商品 item.getProductName() 库存不足); } } cartMapper.deleteBatchIds(cartIds); return order; }注意第4步的deductStock它的SQL条件是UPDATE tb_product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}。多线程同时下单时这保证了不会超卖数据库行锁会串行化同一商品的并发更新后执行的请求发现库存不足时受影响行数为0抛出异常触发整体回滚。这个设计的本质是把库存检查与扣减合成为一条原子SQL而不是“先查再改”的两步操作。代码中Transactional(rollbackFor Exception.class)覆盖了BusinessException若不加rollbackFor且未指定事务的rollbackForClassName遇到受检异常时事务不会自动回滚生成一半的脏数据会留在库表里。这里完成的事务化操作放到实践环节可以看到购物车条目在扣减库存成功后才被批量删除整个过程顺理成章。6. 验证登录态、排查启动异常和Mock支付调试6.1 启动顺序与环境验证拿到源码后先别急着解压。建议按下面的顺序跑通本地环境每步验证一个关键点。# 第一步创建数据库并导入 mysql -uroot -p sql/cake_shop.sql # 第二步修改数据库连接配置 vim src/main/resources/jdbc.properties # jdbc.urljdbc:mysql://localhost:3306/cake_shop?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai # jdbc.usernameroot # jdbc.password你的密码 # 第三步启动Tomcat并观察日志 mvn clean package启动后如果日志出现BeanCreationException九成是数据库连接没配对。如果页面渲染出来是乱码检查serverTimezone和characterEncoding两个参数。Tomcat的URI编码默认是UTF-8若仍乱码则在server.xml的Connector上添加URIEncodingUTF-8。6.2 登录态的验证方法手写一个测试接口系统的登录拦截器配置在spring-mvc.xml中项目源码里能否通过未登录拦截验证可以用下面这种方式快速自测。临时在CartController加一个测试方法输出当前会话里的用户信息。GetMapping(/checkLogin) ResponseBody public User checkLogin(HttpSession session) { User user (User) session.getAttribute(loginUser); System.out.println(当前登录用户: (user null ? null : user.getUsername())); return user; }浏览器访问/cart/checkLogin如果被拦截器重定向到登录页说明拦截器整体工作正常如果返回了null说明拦截器放行了此时要检查exclude-mappings是否配置过宽、把/cart/**下面的子路径也排除了。登录成功后再访问同名接口看到用户对象时整个链路就通了。6.3 订单模块用Mock支付联调开发环境没有真实支付渠道我一般习惯做一层PaymentService接口。订单状态从0推到1只在Controller层调用mockPay实现这样数据库文档里预置的支付回调字段在联调时可以直接用。Service(mockPaymentService) public class MockPaymentService implements PaymentService { Override public boolean mockPay(String orderNo) { // 模拟支付平台回调直接更新订单状态 Order order orderMapper.selectByOrderNo(orderNo); if (order ! null order.getStatus() 0) { orderMapper.updateStatus(order.getId(), 1); return true; } return false; } }实际调试支付回调时更贴近真实扫描支付状态的方式是让mockPay先休眠两秒再更新状态这样能观察到浏览器端按钮的防重复点击效果配合前端disabled属性更贴近线上行为。Mock支付验证通过后再切换到正式的支付SDK时只需替换PaymentService的注解注入实现类即可。本文还有配套的精品资源点击获取
返回列表