资讯动态

JSP+SSM网上购物商城系统实战:数据库设计与核心模块实现全解析

发布时间:2026/10/20 4:41:45 来源:尧图企业网站定制
买过几次线上课程里的商城项目自己也带过几个新人跑完这种经典组合。如果你正在折腾基于 JSP 和 SSMSpring SpringMVC MyBatis的网上购物商城系统不管是为了课程设计、毕业设计还是单纯想熟悉 Java Web 的完整开发链路这篇文章应该能帮你省下不少弯路。这类项目与其说“老”不如说“经典”。它踩中的不是最新潮的技术而是一整套 Java Web 开发里绕不开的核心概念MVC 分层、IOC 容器、声明式事务、ORM 映射、会话保持、拦截器设计。把这些东西吃透后续学 Spring Boot、微服务很多思路都是相通的。我先说结论这个系统完全可以落地成一套可演示、可扩展、可答辩的完整项目。下面我会按自己实际搭过的方案把整体设计、数据库建模、框架整合、核心模块实现、以及遇到的坑全部拆开讲。1. 项目整体设计与技术选型拆解1.1 为什么是 JSP SSM而不是直接上 Spring Boot很多人拿到这个标题第一反应是“JSP 都过时了为什么还要做”。但作为过来人我建议你别急着换技术栈。这套组合在高校教学和企业遗留系统里仍然有大量应用选它至少有四个实际好处学习曲线平滑JSP 做视图层SSM 做控制和业务层每一层都能单独调试非常适合理解 Web 请求的完整流转过程。面试和答辩好讲SSM 是 Java 工程师岗位的高频考点你能把 Spring 的 IOC、AOPMyBatis 的动态 SQL 讲清楚比单纯背 Spring Boot 注解更有说服力。事务和 SQL 控制更直观SSM 方式下SQL 写在 Mapper 里事务通过 Spring 管理你能真真切切看到数据库层面发生了什么。部署环境友好传统 Tomcat WAR 包部署方式在实验室、老服务器上依然非常常见兼容性比打包成 Jar 的 Boot 项目更稳。如果你已经有 Spring Boot 基础反过来看 SSM 反而能理解很多 Boot “自动配置”背后到底干了什么。换句话说它是底子不是累赘。1.2 整体分层谁负责干什么我在实际开发里采用的是标准的四层结构项目包名可以按照这个思路来组织controller接收请求做参数校验调用 service返回转发或重定向。只做调度不写业务。serviceservice.impl业务逻辑核心例如下单时要先查库存、再锁库存、最后生成订单。dao/mapperMyBatis 的数据访问接口配合 XML 写 SQL只做数据库读写。pojo/entity/domain实体类和数据库表字段一一对应。另外还有两个贯穿全局的东西vo视图对象和dto传输对象。我习惯把查询结果封装成vo避免把数据库实体直接扔到页面。尤其是商品列表需要展示“分类名称”而实体里只有“分类ID”时建一个GoodsVO比硬凑字段省心得多。1.3 核心功能模块划分一个完整的购物商城在功能上至少覆盖以下模块这也是我在设计数据库表时反复对需求的原因前台用户端注册、登录、浏览商品、按分类筛选、关键字搜索、商品详情、加入购物车、修改购物车、下单结算、订单列表、订单详情。后台管理端管理员登录、商品管理增删改查、上下架、分类管理、订单状态管理发货、完成、用户管理、销量统计。通用能力分页、文件上传商品图片、非法访问拦截、统一异常处理。看起来功能不少但只要数据库设计得清楚代码层面就是“增删改查 状态流转”的重复变体。真正容易翻车的反而是购物车的数据结构、订单状态机设计、事务控制这几个点后面我会专门展开。2. 数据库建模先把表设计好后面能少写一半代码建表是整个项目的地基。我见过太多同学上来就写代码写到订单模块才发现缺少字段然后推倒重来。这里我按实际商城系统的通用做法给出六张核心表。2.1 六张核心表的结构说明用户表user字段名类型说明idint主键自增usernamevarchar(50)登录名唯一passwordvarchar(100)存储加密后的密码phonevarchar(20)可空create_timedatetime创建时间商品表goods字段名类型说明idint主键category_idint外键关联分类表namevarchar(100)商品名称subtitlevarchar(200)卖点副标题main_imagevarchar(255)主图路径pricedecimal(10,2)单价stockint库存statustinyint1 上架0 下架分类表category、购物车表cart、订单表order、订单明细表order_item分别承担各自的职责。这里我特别提醒三个容易忽视的点第一订单金额不要只存单价乘以数量必须单独存一个total_price字段。因为订单生成之后商品可能改价历史订单要保留下单那一刻的成交金额。第二库存超卖问题。如果在 service 层采取“先查库存再减库存”的方式高并发下会超卖。实际项目里我用了UPDATE goods SET stock stock - #{num} WHERE id #{goodsId} AND stock #{num}把判断和扣减合成一条 SQL数据库行锁天然挡住超卖。第三购物车表设计。有人喜欢用 JSON 存购物车但在这个项目里我建议用关系表因为要支持“用户 A 和用户 B 各自拥有独立购物车”的隔离关系表最清晰。CREATE TABLE cart ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, goods_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, checked TINYINT DEFAULT 1, UNIQUE KEY uk_user_goods (user_id, goods_id) );catalog_id 加上唯一索引(user_id, goods_id)后我直接使用INSERT ... ON DUPLICATE KEY UPDATE来处理“加购时如果已经存在就累加数量”的场景少写一次查询。2.2 订单与明细为什么必须拆成两张表订单主表记录一次购买的整体信息订单号、用户ID、总金额、状态、收货地址订单明细表记录这次订单里具体买了哪些商品、每个商品什么价格。如果不拆一个订单有 5 种商品就得插入 5 条重复的订单记录数据冗余不说后续统计“客单价”“复购率”都没法做。CREATE TABLE order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE, user_id INT, total_price DECIMAL(10,2), status TINYINT, receiver_name VARCHAR(20), receiver_phone VARCHAR(20), receiver_address VARCHAR(255), create_time DATETIME ); CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT, goods_id INT, goods_name VARCHAR(100), goods_image VARCHAR(255), goods_price DECIMAL(10,2), quantity INT );注意订单明细里的goods_name、goods_image、goods_price都是冗余字段。这属于刻意冗余因为订单一旦生成不管商品以后改名、改价还是图片被替换历史订单都要显示当时的信息。如果直接关联商品表商品一改订单全跟着变从业务角度是不可接受的。2.3 订单状态用数字字典管理别写死我在代码里没有用魔法数字而是在项目里定义一个OrderStatusEnumpublic enum OrderStatusEnum { UNPAID(0, 待付款), PAID(1, 已付款待发货), SHIPPED(2, 已发货), RECEIVED(3, 已完成), CANCELLED(4, 已取消); private final int code; private final String desc; }状态流转就三条线正常线0 - 1 - 2 - 3用户取消0 - 4必须是未付款才能取消超时取消0 - 4可以通过定时任务处理演示项目可以先不做但在设计上要留口子每一条状态变更都要求前后状态匹配。比如“付款”操作只允许从 0 变为 1如果现在是 2直接报错。用 MySQL 的UPDATE order SET status 1 WHERE order_no ? AND status 0配合受影响行数判断并发下不会重复操作。3. SSM 整合的关键配置与常见陷阱3.1 Spring 与 SpringMVC 容器拆分这是最容易踩坑的地方。Spring 的 IOC 容器负责管理 service、daoSpringMVC 的容器负责管理 controller。两者如果扫描范围重叠会导致事务注解失效。我的配置做法是Spring 的配置文件只管service、dao、数据源、事务扫描路径写com.example.mall.service。SpringMVC 的配置文件只扫描com.example.mall.controller同时配置 JSP 视图解析器。web.xml 里这样安排servlet servlet-namedispatcherServlet/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:springmvc-config.xml/param-value /init-param load-on-startup1/load-on-startup /servlet3.2 MyBatis 的 Mapper 扫描与 XML 路径MyBatis 配置文件里我设置了两类关键配置bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.mall.dao/ /beanMapper 接口放在com.example.mall.daoXML 文件放在resources/mapper/目录下两者通过命名空间和接口全限定名匹配。这里有个血泪教训如果你的 Mapper XML 没有放在 resources 目录而是放在了 java 目录下构建时 XML 文件不会被复制到 classes 目录启动时就会报 “Invalid bound statement”。解决方案是在 pom.xml 里显式配置build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource resource directorysrc/main/resources/directory /resource /resources /build3.3 事务配置的两种方式我在项目里用注解驱动事务核心配置只有三行bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/然后在 service 实现类上加Transactional。注意注解要加在 public 方法上并且不要在同类的内部方法调用中失效。例如一个类里methodA调methodB如果methodB加了Transactional由于走的是this直接调用而不是代理对象事务不会生效。要避免这种隐藏坑可以把事务边界放到 controller 调 service 的那一层保证事务方法是被外部对象调用的。3.4 静态资源放行SpringMVC 的 DispatcherServlet 会拦截所有/请求如果不放行静态资源CSS、JS、图片会全部 404。两种方式二选一方式一在 springmvc 配置里加mvc:resources location/static/ mapping/static/**/方式二在 web.xml 里加 default servlet 映射。我推荐方式一更直观且不会影响其他映射。4. 核心功能模块的实操实现笔记4.1 用户注册与登录密码处理与会话保持密码绝不能明文存。这里我用的是加盐 MD5用用户名作为盐、再拼接随机盐做双重处理public static String encrypt(String username, String password) { String base username password SALT; return DigestUtils.md5DigestAsHex(base.getBytes(StandardCharsets.UTF_8)); }注册时先查用户名是否已存在存在则直接返回提示。登录成功后将用户对象存入 Session同时写一个拦截器检查用户是否登录未登录则跳转到登录页。拦截器是核心防护手段。我写了一个LoginInterceptor实现HandlerInterceptorOverride public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user request.getSession().getAttribute(user); if (user null) { response.sendRedirect(request.getContextPath() /user/login); return false; } return true; }在 springmvc 配置里对/user/**、/cart/**、/order/**等路径全部拦截但对登录页、注册接口、商品列表、详情页放行。4.2 商品查询与分页的实现细节商品列表的分页我直接写了一个简单的 PageBean 封装public class PageBeanT { private int pageNum; // 当前页 private int pageSize; // 每页条数 private long total; // 总条数 private int pages; // 总页数 private ListT list; // 当前页数据 }SQL 里用 limit 做物理分页这是项目中最易出错的环节之一。错误写法是直接拼接${page}导致 SQL 注入正确写法是用#{offset}, #{pageSize}传参参数由代码计算。我习惯在 service 层做一次计算再传给 daoint offset (pageNum - 1) * pageSize;搜索功能同理用 MyBatis 动态 SQL 拼接关键字条件select idsearchGoods resultTypeGoodsVO SELECT g.*, c.name AS category_name FROM goods g LEFT JOIN category c ON g.category_id c.id where if testkeyword ! null and keyword ! AND g.name LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND g.category_id #{categoryId} /if AND g.status 1 /where /selectLIKE 查询在数据量大的时候性能会下降但这个项目阶段用不到搜索引擎主要靠数据库索引。给goods.name加上普通索引即可满足绝大多数演示场景。4.3 购物车的增删改与勾选结算购物车页面我实现了“单选/全选”功能这个功能做起来不难难点在于勾选状态需要和数据库同步因为结算时必须判断用户勾了哪些商品。我设计了一个CartController用 AJAX 请求实现数量和勾选状态的即时更新前端用 jQuery 发送请求function updateQuantity(cartId, quantity) { $.post(/cart/update, { cartId: cartId, quantity: quantity }, function(result) { if (result.code 0) { refreshTotal(); } else { alert(result.msg); } }, json); }加购时如果同一种商品已经存在直接更新数量否则插入新记录。这正好用上了前面那张表的唯一索引和ON DUPLICATE KEY UPDATEinsert idaddOrUpdate INSERT INTO cart (user_id, goods_id, quantity) VALUES (#{userId}, #{goodsId}, #{quantity}) ON DUPLICATE KEY UPDATE quantity quantity #{quantity} /insert4.4 下单结算库存扣减事务是关键下单接口是事务敏感度最高的地方我把整个流程放进一个事务方法里包含五步查询用户当前勾选的购物车记录。循环校验商品当前状态、库存是否足够。用“带条件的 update”扣库存。生成订单主记录获取自增 ID。遍历购物车明细生成 order_item删除对应购物车记录。下单方法核心代码Transactional(rollbackFor Exception.class) public Order createOrder(Integer userId, Long receiverAddressId) { ListCart cartList cartMapper.selectCheckedByUserId(userId); if (cartList.isEmpty()) { throw new BusinessException(请先勾选商品); } BigDecimal totalPrice BigDecimal.ZERO; ListOrderItem itemList new ArrayList(); for (Cart cart : cartList) { Goods goods goodsMapper.selectById(cart.getGoodsId()); if (goods null || goods.getStatus() ! 1) { throw new BusinessException(商品不存在或已下架); } if (goods.getStock() cart.getQuantity()) { throw new BusinessException(商品【 goods.getName() 】库存不足); } int rows goodsMapper.decreaseStock(cart.getGoodsId(), cart.getQuantity()); if (rows 0) { throw new BusinessException(商品【 goods.getName() 】库存不足扣减失败); } totalPrice totalPrice.add(goods.getPrice().multiply(new BigDecimal(cart.getQuantity()))); // 组装明细 } // 插入订单主表 // 批量插入明细 // 清空购物车勾选项 return order; }注意decreaseStock的 SQLUPDATE goods SET stock stock - #{quantity} WHERE id #{goodsId} AND stock #{quantity}如果受影响行数为 0说明库存不足立即抛异常回滚。这一条 SQL 同时完成了“判断库存”和“扣减库存”直接用数据库行锁保证并发安全比起先查再减的方案稳得多。扣库存语句执行后再删除购物车记录。如果这步失败整个事务会回滚购物车数据不会丢这是事务的底线价值。自己实现时千万不能把清空购物车放到事务方法外面。4.5 后台管理文件上传与商品上下架后台商品管理中文件上传用 CommonsMultipartResolver配置好上传大小限制bean idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namedefaultEncoding valueUTF-8/ property namemaxUploadSize value5242880/ /bean上传后把图片保存到 Tomcat 外的某个目录再通过虚拟路径映射访问。我建议给图片文件统一用 UUID 命名避免中文文件名和多线程并发重名导致的乱码或覆盖。商品上下架状态通过 status 字段控制。前台查询天然带AND g.status 1所以下架立即生效不需要额外处理缓存。5. 高频 Bug 与排查经验实录这部分是我最想分享的因为理论代码网上很多但真正让人卡壳的往往是小问题。5.1 中文乱码问题JSP 页面、Servlet 过滤器、数据库连接三个环节都可能产生乱码。我最终的解决方案是JSP 页面顶部设置pageEncodingUTF-8和contentTypetext/html; charsetUTF-8。web.xml 配置一个CharacterEncodingFilter强制请求和响应统一为 UTF-8。数据库连接 URL 后面必须带参数characterEncodingutf-8。MySQL 建库时使用utf8mb4字符集。排查乱码时按“请求参数乱码 - 页面显示乱码 - 数据库存取乱码”三个方向逐一排除。最常见的坑是只配了过滤器忘了数据库连接 URL导致页面正常但入库就乱。5.2 静态资源路径 404如果你发现 CSS 样式加载不出来先按 F12 看 Network 面板里的请求路径。我经常见到的问题是使用了绝对路径在根目录部署没问题但项目挂载到二级路径时就找不到资源。通用做法是${pageContext.request.contextPath}前缀拼接所有 URLlink relstylesheet href${pageContext.request.contextPath}/static/css/style.css或者利用 JSTL 的c:url标签它会自动加上 contextPath。后端重定向也用redirect:/方式避免手写路径。5.3 事务不生效前面提到内部方法调用导致事务失效这是最隐蔽的。我排查的办法是在 service 方法里故意抛一个 RuntimeException看数据库是否回滚。如果不回滚优先检查三件事Spring 配置是否扫描到了 service 包。Transactional是否加在实现类而不是接口上加在接口上有时会因为动态代理方式不生效。是否因为同一个类内部方法调用导致代理失效。5.4 分页查询时 total 莫名不对MyBatis 分页时我早期习惯写两个查询一个查 list一个查 count。如果 count 查询里忘了加和 list 相同的条件就会出现“总条数和实际数据对不上”的诡异问题。解决办法是写一个专门count查询保持 where 条件完全一致或者引入 PageHelper 插件自动生成 count 语句。个人建议在这个阶段手写 count SQL能加深对分页原理的理解。5.5 数据库连接不上或连接池爆满开发阶段很多人会遇到Cannot create PoolableConnectionFactory先检查 MySQL 服务是否启动、用户名密码是否正确、端口是否是 3306。连接池参数也别乱调最关键的三个是initialSize、maxActive、maxWait。我在测试期间把maxActive设为 20后来模拟并发下单的时候发现连接池爆满。调大maxActive到 50 后好了很多但根本原因是代码里事务方法中 try-with-resources 没有保证连接释放。后来统一用 Spring 管理事务连接由事务管理器负责释放问题才彻底消失。自己排查时重点看有没有在 dao 层手动获取了 SqlSession 但没有 close 的情况。6. 项目扩展与答辩加分思路做到这里系统已经是一个能跑通完整购物流程的项目了。但如果想让它更有竞争力我建议考虑以下几个扩展方向。6.1 加 Redis 做缓存和登录态用 Redis 缓存商品详情用户登录态从 Session 迁移到 Redis Token这样系统能支持多端登录也更贴近真实企业架构。这个改动不需要动 JSP 页面只要在 service 层加缓存逻辑控制器保持不变改造成本很低但是加分很强。6.2 引入消息队列处理下单通知把“下单成功发短信或邮件”这种非核心动作从同步逻辑中分离出来。真实项目里会用到消息队列这个阶段可以先用一个线程池模拟解耦重点讲解削峰填谷的思想。6.3 前后端分离改造JSP 这套视图层对很多同学来说是“面试弱点”。如果时间允许可以把前端改为 Vue AJAX 调用后端接口后端返回 JSON。SSM 的 Controller 天然支持返回 JSONResponseBody改造量主要集中在删除 JSP 逻辑、新增 REST 接口。答辩时可以拿这个作为“我熟悉前后端分离”的证明。注意扩展时不要大改核心流程。先把基础商城稳定跑通保证演示不出错再谈扩展。我自己见过不少项目是主功能都报错却在鞋柜这类小功能上花了大量时间答辩时反而被挑出严重问题得不偿失。7. 最后给新手的几点建议这个项目说难不难说容易也不容易。真正困扰新手的往往不是某个框架用法而是“不知道怎么串起来”。如果你现在还在边看边抄代码我建议按下面这个顺序走一遍每一步都动手不要跳步建库建表手工插入几条测试数据。配好 SSM 框架跑一个查询分类列表的接口确认请求能通到数据库。做用户注册登录。做商品列表和详情。做购物车。做下单。每一步都保证“这一步能演示”不要等到全部写完才启动 Tomcat。我见过的很多翻车现场都是因为最后一次性调所有模块报错信息叠加在一起根本无从下手。另外代码习惯也很重要。包名和类名从一开始就规范命名后面维护会轻松很多。写好异常提示不要抛出看不懂的英文堆栈。给前端返回统一的结果对象比如Result.success(data)和Result.error(msg)前端判断和调试都方便。这套 JSP SSM 的组合跑通之后你会对 Java Web 开发具备完整的全链路感知浏览器发请求、控制器接收、服务层做业务、Mapper 访问数据库、事务保证一致性、视图渲染返回。这些基石再往后延伸到 Spring Boot 和各种微服务中间件都会轻松不少。我自己实践下来的体会是商城项目的坑永远比想象中多但梳理清楚数据流后每一步都有迹可循。年轻人第一次做这种系统能静下心把每一层打通收获的绝对不止一个毕业设计分数。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑