SSM网上购物系统实战:购物车、订单与库存事务解析

发布时间:2026/10/27 10:37:14
SSM网上购物系统实战:购物车、订单与库存事务解析

简介:这份网上购物系统资料包面向课程设计与毕业设计场景,围绕基于Web的商城从前端页面、后台业务到数据库与论文文档展开,适合具备一定Web开发基础、需要完整项目案例参考的学生与开发者,可帮助理解电商系统从需求分析到部署维护的实现路径。压缩包约200.04MB,共2000个文件,以JS、CSS、HTML构建交互与页面结构,ASPX与C#承载后台逻辑,辅以图片、MDB数据库、DOCX论文及DLL、XML等运行配置文件,目录便于按模块查阅。已有81人学习,说明其作为教学与实战素材具有一定参考价值。内容覆盖商品浏览、购物车、订单处理、支付对接、用户中心、验证码与Ajax交互、后台配置等环节,并附论文文档,可对照需求分析、系统设计、关键代码与测试思路。对完成课程设计、毕业设计或二次开发的人来说,还能借助上述模块的实现细节,快速搭建同类商城并完善答辩材料。

1. 从一个答辩前的返工说起:基于 Web 的网上购物系统交付了什么

答辩前一周,A 同学打开导师发回的批注,第一行写着「购物车加了商品,刷新就没了」。他手上这份基于 Web 的网上购物系统,源码能启动、页面也齐全,但一处 Session 作用域写错,整条下单链路就断在第二步。这类项目最实在的价值不在论文有多少页,而在于它用一套能跑的三层结构,把商品浏览、加入购物车、提交订单、后台管理这条完整交易链完整地摆在你面前:SSM 打底、JSP 渲染、MySQL 落库,前台商城与后台管理两个入口共用一套 Service。它适合拿它当毕设底稿、需要一份结构完整可讲清的作品的人,也适合想找一个体量可控的 Java Web 项目练手、准备二次开发的开发者。

2. 技术栈与目录结构:SSM 三层到底落在哪些文件夹上

拿到源码包先别急着点运行,把压缩包解开,看一眼pom.xml和src/main下面的划分,基本就能判断这套代码是「真分了层」还是「Controller 里写 SQL」。网上购物系统这个题材的毕设项目,绝大多数走的是 Spring + SpringMVC + MyBatis 这套组合,因为它在论文里好画图、在答辩时好讲、在本地也好部署——不依赖任何外部服务,一个 Tomcat 加一个 MySQL 就能全跑起来。这一章把技术选型和目录结构对着看一遍,后面读代码才不会迷路。

2.1 技术选型清单与每一层负责什么

先把这套项目常见的技术栈摊开。它不是一个追新的组合,但每一件都有明确的理由:

层次选型在这套系统里承担的事
表现层JSP + JSTL + Bootstrap + jQuery商品列表、详情、购物车、订单页的服务端渲染与局部异步
控制层SpringMVC@Controller接收表单、参数校验、跳转与 JSON 返回
业务层Spring@Service+ 声明式事务下单、扣库存、购物车合并等跨表逻辑
持久层MyBatis Mapper 接口 + XML手写 SQL,商品条件查询与订单关联查询
数据库MySQL 5.7 / 8.0用户、商品、分类、购物车、订单、订单明细
容器Tomcat 8.5 / 9以 war 包方式部署,上下文路径即项目名
辅助Druid 连接池、PageHelper 分页、Log4j连接管理、分页插件、运行日志

为什么表现层用 JSP 而不是前后端分离?这是这套项目最容易被吐槽、但也最合理的一处取舍。毕设场景往往在断网或者受限网络的机房里答辩,前端打包、Node 环境、接口联调都是额外风险;JSP 直接在 Tomcat 里渲染,改一行刷新就见效,论文里画 MVC 时序图时,请求从.jsp到DispatcherServlet再到Service的路径也清清楚楚。代价是页面逻辑和 Java 代码耦合,<c:forEach>里嵌判断、表单参数名和 Controller 参数名必须一字不差,改起来容易牵一发动全身。你如果打算二次开发成正式项目,第一件要动的就是这一层。

业务层是这套系统真正的重心。购物车合并、下单扣库存、订单状态流转,这三件事都有事务边界,写错了就会出现「订单生成了但库存没减」「购物车合并后重复条目」这类只有压测或者手工点两遍才会暴露的问题。持久层选择 MyBatis 而不是 JPA,也是因为商品列表那种「关键词 + 分类 + 价格区间 + 排序 + 分页」的多条件查询,手写 XML 里的<if>比拼 Criteria 直观得多,出了问题把 SQL 打到日志里就能看。

2.2 目录结构逐层对照:从 webapp 到 mapper

把源码展开,标准 Maven Web 工程的骨架大致是这样,每一行都对应一个职责,认准这张表,后面找代码就不用全局搜索了:

路径内容说明
src/main/java/com/xxx/controllerGoodsController、CartController、OrderController、AdminController按业务域拆,前台后台共用一个包
src/main/java/com/xxx/service接口 +impl实现事务注解打在 impl 上,不在接口上
src/main/java/com/xxx/mapperMapper 接口与resources/mapper/*.xml一一对应
src/main/java/com/xxx/entityUser、Goods、Cart、Orders、OrderItem纯 POJO,字段名与表字段驼峰对应
src/main/java/com/xxx/interceptorLoginInterceptor、AdminInterceptor登录态与后台权限拦截
src/main/java/com/xxx/utilMD5Util、OrderNoUtil、Result密码加盐、订单号生成、统一返回
src/main/resourcesapplicationContext.xml、spring-mvc.xml、mybatis-config.xml、jdbc.properties三个配置文件各管一段
src/main/webapp/WEB-INF/jspindex.jsp、goods_list.jsp、cart.jsp、order_list.jsp、admin/*前台与后台按子目录分开
src/main/webapp/staticcss、js、images、upload商品图片上传目录,注意部署后的写入权限

配置文件的分工值得单独说一句,这是新手最容易改错的地方。applicationContext.xml管 Service、数据源、事务管理器、MyBatis 的SqlSessionFactory;spring-mvc.xml管扫描@Controller、视图解析器前缀后缀、静态资源放行、拦截器注册;web.xml负责把这两个容器串起来,同时配置CharacterEncodingFilter。三者的扫描范围不能重叠,否则事务会失效——这是个经典现象:Service 被 SpringMVC 容器扫走了,@Transactional因为没经过代理对象而静默不生效,代码看起来毫无问题,数据就是回滚不了。

控制器长什么样,看一段真实的商品查询入口就很清楚了:

@Controller @RequestMapping("/goods") public class GoodsController { @Autowired private GoodsService goodsService; @Autowired private CategoryService categoryService; // 商品列表:关键词、分类、价格区间、排序、页码全部走 query 参数 @RequestMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "12") Integer pageSize, String keyword, Integer categoryId, BigDecimal minPrice, BigDecimal maxPrice, String sort, Model model) { // PageHelper 的 startPage 必须紧贴查询语句,中间不能插入别的 SQL PageHelper.startPage(pageNum, pageSize); List<Goods> goodsList = goodsService.query(keyword, categoryId, minPrice, maxPrice, sort); PageInfo<Goods> pageInfo = new PageInfo<>(goodsList); model.addAttribute("pageInfo", pageInfo); model.addAttribute("categoryList", categoryService.listAll()); // 左侧分类树 // 回填查询条件,翻页时不至于把筛选条件丢掉 model.addAttribute("keyword", keyword); model.addAttribute("categoryId", categoryId); model.addAttribute("sort", sort); return "goods_list"; // 视图解析器拼成 /WEB-INF/jsp/goods_list.jsp } }

这段代码里有三个参数值得留意。pageNum与pageSize给了默认值,意味着/goods/list不带任何参数也能打开首页,这一点直接决定了你启动项目后访问根路径会不会 500。sort是字符串而不是枚举,常见做法是在 Mapper 里用<choose>白名单映射成price asc、sales desc这样的固定片段,绝不能直接${sort}拼进 SQL,否则就是一个现成的注入点。Model里回填keyword和categoryId,是为了让goods_list.jsp的分页链接能带着筛选条件一起翻页,不做这一步的现象是「翻到第二页,筛选条件全丢了」。

2.3 数据库表设计:六张核心表撑起整条交易链

表结构是这套系统的地基,读懂六张表的外键关系,整条业务链路自然就通了。下面是这类项目最常见的建表方案,字段做了归一化处理:

-- 用户表:role 区分 0 普通用户 / 1 管理员,避免维护两套登录 CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(64) NOT NULL COMMENT 'MD5 加盐后的密文', `phone` VARCHAR(20), `address` VARCHAR(255) COMMENT '默认收货地址,简化版不单独建地址表', `role` TINYINT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品表:stock 是下单时唯一需要保证一致性的字段 CREATE TABLE `goods` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL, `category_id` INT NOT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `image` VARCHAR(255), `detail` TEXT, `status` TINYINT DEFAULT 1 COMMENT '1 上架 0 下架', `sales` INT DEFAULT 0, KEY `idx_cate` (`category_id`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表:total_amount 冗余存储,避免每次列表都去 sum 明细 CREATE TABLE `orders` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `user_id` INT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0 待付款 1 已付款 2 已发货 3 已完成 4 已取消', `receiver` VARCHAR(50), `receiver_phone` VARCHAR(20), `receiver_address` VARCHAR(255), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细:price 必须在下单瞬间冻结,不能关联 goods.price 实时取 CREATE TABLE `order_item` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_id` INT NOT NULL, `goods_id` INT NOT NULL, `goods_name` VARCHAR(100), `price` DECIMAL(10,2) NOT NULL, `quantity` INT NOT NULL, KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

三个设计点值得单独拎出来讲。order_item里冗余了goods_name和price,这不是偷懒,而是必须——商品改名或者调价之后,历史订单显示的必须还是下单当时的信息,如果靠goods_id去 join 实时取,用户翻旧订单会发现金额变了,这是电商系统最低级的翻车。orders.total_amount同样是冗余,订单列表页一次要查十几条,每条再 sum 一次明细,性能立刻塌下来。goods.stock没有做成「扣减流水表 + 汇总」的复杂结构,因为在单机部署、并发量很低的毕设场景下,一条UPDATE goods SET stock = stock - #{n} WHERE id = #{id} AND stock >= #{n}加上判断影响行数,就足够防超卖了,把复杂方案塞进来反而增加答辩时讲不清的风险。

购物车表cart与分类表category结构更简单,前者是user_id + goods_id + quantity的唯一组合,后者是id + parent_id + name的自关联两级结构。注意cart上要建(user_id, goods_id)的唯一索引,否则「同一商品加两次」会变成两条记录,页面上出现重复行,用户在购物车里删一条还剩一条,这也是新人调试时经常卡住的地方。

3. 核心链路拆解:从商品列表到订单落库的一次完整请求

结构看清了,接下来走一遍主干。用户在首页点开一个商品,加进购物车,登录后去结算,提交订单,库存减一、订单表插一条、明细表插若干条。这条链路横跨四个 Controller、三个 Service、四个 Mapper,任何一环的事务或者参数对不上,表现都是「页面没反应」或者「订单出来了但金额不对」。这一章按顺序把这四段代码拆开,每段都能直接对着自己项目里的文件改。

3.1 商品列表与多条件查询:MyBatis 动态 SQL 怎么写才不出错

列表页是一套商城系统的门面,也是动态 SQL 用得最密的地方。下面的GoodsMapper.xml片段几乎是这类项目的标配:

<select id="query" resultType="com.xxx.entity.Goods"> SELECT id, name, category_id, price, stock, image, sales, status FROM goods <where> status = 1 <!-- 前台只查上架商品,下架的不露头 --> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="minPrice != null"> AND price &gt;= #{minPrice} </if> <if test="maxPrice != null"> AND price &lt;= #{maxPrice} </if> </where> <!-- 排序用白名单映射,禁止 ${sort} 直拼 --> <choose> <when test="sort == 'priceAsc'">ORDER BY price ASC</when> <when test="sort == 'priceDesc'">ORDER BY price DESC</when> <when test="sort == 'salesDesc'">ORDER BY sales DESC</when> <otherwise>ORDER BY id DESC</otherwise> </choose> </select>

<where>标签会自动处理开头的AND,这一点很多人手写时忘了,拼出WHERE AND name LIKE ...直接语法错误。<if>里判断空串必须同时判null和'',前端表单提交空输入框传上来的是空字符串而不是 null,只判 null 会导致「什么都没填,却在 SQL 里多出一个LIKE '%%'」,全表扫描一遍。价格区间的&gt;和&lt;是 XML 转义,直接写>=会被解析器当成标签开头,配置文件启动就报错,这个报错信息指向的行号往往是错的,找起来很费劲。

排序那段是重点。ORDER BY后面用#{}会变成字符串常量,排序完全失效;用${}又开了注入口子。正确做法就是上面这样,用<choose>把前端传来的枚举值映射成固定的 SQL 片段,前端只能传priceAsc这种东西,传别的落到otherwise。同理,分页参数pageNum在 Service 里也要做一次pageNum < 1 ? 1 : pageNum的兜底,否则用户手改 URL 传个-1,PageHelper 会抛出让人一头雾水的偏移量异常。

分页本身靠 PageHelper 的startPage加PageInfo,但有个细节必须注意:PageHelper.startPage()只对紧随其后的第一条查询生效。如果你在它和真正的查询之间插了一句count或者查分类的 SQL,分页就会作用到错误的对象上,PageInfo里的total也会变成一个荒唐的数字。我在这种地方的习惯是把startPage写在 Service 方法的第一行,紧接着就是 Mapper 调用,中间不留任何空隙。

3.2 购物车:未登录走 Session,登录后合并入库

购物车是这套系统里状态最纠结的模块。用户没登录也要能加商品,登录之后这些商品还得跟账号里的购物车合到一起。这类项目普遍采用双写方案:未登录时把Map<Integer, Integer>(商品 ID 到数量)塞进 Session,登录成功后一次性合并进cart表并清空 Session。

@Service public class CartServiceImpl implements CartService { @Autowired private CartMapper cartMapper; /** * 登录成功后调用:把 Session 里的临时购物车合并进数据库 * @param userId 当前登录用户 * @param session 未登录期间累积的购物车,key 为 goodsId,value 为数量 */ @Override @Transactional(rollbackFor = Exception.class) public void mergeSessionCart(Integer userId, HttpSession session) { Map<Integer, Integer> tempCart = (Map<Integer, Integer>) session.getAttribute("tempCart"); if (tempCart == null || tempCart.isEmpty()) { return; // 没有临时数据,直接返回,避免无谓的事务开销 } for (Map.Entry<Integer, Integer> entry : tempCart.entrySet()) { Integer goodsId = entry.getKey(); Integer quantity = entry.getValue(); Cart exist = cartMapper.selectByUserAndGoods(userId, goodsId); if (exist == null) { Cart cart = new Cart(); cart.setUserId(userId); cart.setGoodsId(goodsId); cart.setQuantity(quantity); cartMapper.insert(cart); } else { // 已存在则数量累加,不能覆盖,否则用户之前加的就白加了 cartMapper.addQuantity(exist.getId(), quantity); } } session.removeAttribute("tempCart"); // 合并完必须清掉,不然换个账号登录会串数据 } }

这段代码有三处关键。第一,@Transactional加在实现类的方法上并指定rollbackFor = Exception.class,Spring 默认只对RuntimeException回滚,如果合并过程中抛的是受检异常,前面插进去的几条数据就留在库里了。第二,exist == null的分支不能省,直接insert会因为唯一索引冲突报错,而如果没建唯一索引,就会插出重复行。第三,session.removeAttribute这行看着不起眼,但漏掉它的现象非常典型:A 用户加了几件商品没登录,登录后合并了,退出,B 用户在同一浏览器登录,购物车里凭空多了 A 的东西——排查时几乎没人会想到去看 Session 残留。

数量累加那条 SQL 也值得一看,UPDATE cart SET quantity = quantity + #{n} WHERE id = #{id},用数据库的原子加法而不是「查出来 +1 再写回去」,后者在并发下会丢更新。再加上前面说的(user_id, goods_id)唯一索引,这一套下来购物车模块基本就稳了。

购物车页面本身的渲染逻辑,cart.jsp里通常是一个<c:forEach>循环,每行带一个数量输入框和一个删除按钮。数量变化走 jQuery 的$.post提交到/cart/update,回调里重新计算小计和总计。这里有个常见的玄学问题:改数量后总价不刷新,原因是回调里只更新了行小计,忘了重算底部合计;或者更隐蔽的一种,后端返回的金额是BigDecimal,前端直接+=拼字符串,出现199.00199.00这种结果——金额在 JS 侧务必parseFloat之后再格式化。

3.3 下单:库存扣减、订单号生成与事务边界的落点

下单是整个系统里唯一必须严格保证一致性的地方,也是最容易写出「看起来对、跑起来错」的一段。核心逻辑是三步:校验库存、扣减库存、写订单与明细,三步必须在同一个事务里。

@Service public class OrderServiceImpl implements OrderService { @Autowired private GoodsMapper goodsMapper; @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper orderItemMapper; @Autowired private CartMapper cartMapper; @Override @Transactional(rollbackFor = Exception.class) public String submit(Integer userId, List<Cart> cartList, String receiver, String phone, String address) { if (cartList == null || cartList.isEmpty()) { throw new RuntimeException("购物车为空,无法提交订单"); } BigDecimal total = BigDecimal.ZERO; List<OrderItem> items = new ArrayList<>(); for (Cart cart : cartList) { Goods goods = goodsMapper.selectById(cart.getGoodsId()); if (goods == null || goods.getStatus() != 1) { throw new RuntimeException("商品已下架:" + cart.getGoodsId()); } // 带条件的更新:stock >= quantity 写进 WHERE,靠影响行数判断是否扣成功 int rows = goodsMapper.reduceStock(cart.getGoodsId(), cart.getQuantity()); if (rows == 0) { throw new RuntimeException("库存不足:" + goods.getName()); // 触发整体回滚 } OrderItem item = new OrderItem(); item.setGoodsId(goods.getId()); item.setGoodsName(goods.getName()); item.setPrice(goods.getPrice()); // 下单瞬间的价格,冻结进明细 item.setQuantity(cart.getQuantity()); items.add(item); total = total.add(goods.getPrice().multiply(new BigDecimal(cart.getQuantity()))); } Orders order = new Orders(); order.setOrderNo(OrderNoUtil.generate(userId)); // 时间戳 + userId 后两位 + 随机数 order.setUserId(userId); order.setTotalAmount(total); order.setStatus(0); order.setReceiver(receiver); order.setReceiverPhone(phone); order.setReceiverAddress(address); orderMapper.insert(order); // 主键回填后才有 order.getId() for (OrderItem item : items) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } cartMapper.deleteByUser(userId); // 下单成功后清空购物车 return order.getOrderNo(); } }

reduceStock这条 SQL 是整个下单逻辑的命门,它必须是这个形状:

<update id="reduceStock"> UPDATE goods SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{goodsId} AND stock >= #{quantity} </update>

把stock >= #{quantity}放进WHERE而不是先select出来在 Java 里判断,是为了让「判断 + 扣减」在数据库里一次原子完成。如果分成两步,两个用户同时下单同一件最后一件商品,两边都读到stock = 1,都判断通过,最后扣成-1。这种 bug 本地单人点击永远测不出来,答辩时老师问一句「并发怎么办」就容易哑火。影响行数为 0 就抛异常,@Transactional会把前面已经扣掉的几件商品库存一起回滚,这正是把整个循环包在一个事务里的意义。

订单号生成用时间戳加用户标识加随机数,yyyyMMddHHmmss + userId 后两位 + 三位随机,长度控制在 32 以内,配合表上的唯一索引。不要用snowflake之类的方案,单机项目里它带来的是一堆讲不清的位运算,收益为零。还有一处容易被忽略:orderMapper.insert(order)之后order.getId()才有值,这是 MyBatis 的useGeneratedKeys在起作用,如果 XML 里漏了useGeneratedKeys="true" keyProperty="id",明细表插进去的order_id全是 null,订单详情页一条明细都查不出来。

3.4 后台管理:登录拦截器与商品上下架

后台模块的技术含量不高,但权限这块如果漏了,是实打实的安全问题——普通用户手敲/admin/goods/list就能进后台。这类项目通常用一个拦截器统一处理。

public class AdminInterceptor 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) { response.sendRedirect(request.getContextPath() + "/admin/login"); // 未登录去登录页 return false; } if (user.getRole() == null || user.getRole() != 1) { response.setStatus(403); request.getRequestDispatcher("/WEB-INF/jsp/403.jsp").forward(request, response); return false; // 已登录但不是管理员,拒绝 } return true; } }

拦截器必须在spring-mvc.xml里注册,<mvc:mapping path="/admin/**"/>配<mvc:exclude-mapping path="/admin/login"/>,登录页自己要放行,否则会死循环到浏览器报「重定向次数过多」。同时别忘了<mvc:resources>放行/static/**,不然后台页面样式全丢,登录框变成一堆裸 HTML,新手常以为是 Bootstrap 没引进来。

商品上下架本身只是一条UPDATE goods SET status = ? WHERE id = ?,真正需要注意的是图片上传。上传目录如果放在项目内的webapp/upload,每次重新部署 war 包都会把图片清空,用户昨天传的商品图今天就 404。常见做法是在jdbc.properties旁边加一个upload.path指向 Tomcat 之外的绝对路径,再用<mvc:resources mapping="/upload/**" location="file:${upload.path}"/>映射出去,这样重新部署也不丢文件。

4. 本地跑通:JDK、MySQL、Tomcat 的启动顺序与验证点

代码看明白了,接下来把它跑起来。这套项目的启动链路比前后端分离的项目简单得多,但对版本和顺序很敏感——先启动 Tomcat 再建库,必然报连不上数据库;JDK 版本和编译级别对不上,Unsupported major.minor version直接糊一脸。按下面四步走,能避开绝大部分「跑不起来」的情况。

4.1 环境版本对齐与依赖拉取

先把版本关系钉死,这是最容易出问题的一步:

组件建议版本说明
JDK1.8老项目普遍锁 1.8,用 11 或 17 容易在 JSP 编译或反射处报错
Maven3.6+配置国内镜像,settings.xml里加阿里云仓库
MySQL5.7 或 8.08.0 要注意驱动类名和时区参数,见下
Tomcat8.5 或 9不要用 Tomcat 10,包名从javax.*变成jakarta.*,全项目 import 全红

pom.xml里重点看三处:maven.compiler.source与target是否都是 1.8,MySQL 驱动的版本号,以及<packaging>是 war 还是 jar。是 war 才能丢进 Tomcat,是 jar 说明它内嵌了容器,启动方式完全不同。

拉依赖时如果卡在下载不动,检查settings.xml的镜像配置,同时在 IDEA 里把 Maven 的User settings file指到改过的那个文件上,很多人改的是全局配置但 IDE 用的是 bundled 版本,改了等于没改。依赖拉完先执行一次mvn clean package -DskipTests,能打出 war 包说明编译层面没有问题,再进行下一步。

# 编译并跳过测试,先确认代码本身能过编译 mvn clean package -DskipTests # 产物在 target 目录下,形如 xxx-1.0-SNAPSHOT.war ls -lh target/*.war

注意-DskipTests不是可选项。这类项目里经常残留几个跑不通的测试类,它们要么依赖没配的数据源,要么断言的是几年前的字段名,直接mvn package会在测试阶段就中断,而你误以为是源码有 bug。

4.2 建库导数据与改配置文件

数据库这一步,源码包里一般会带一个xxx.sql,可能是建表加初始数据,也可能只有建表语句。导入顺序是先建库再导表:

# 1. 建库,字符集必须 utf8mb4,否则商品名里的生僻字和表情会乱码 mysql -u root -p -e "CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入建表与初始数据,注意路径换成你解压后的实际位置 mysql -u root -p shop < sql/shop.sql # 3. 验证:能看到表和几行初始数据就对了 mysql -u root -p shop -e "SHOW TABLES; SELECT COUNT(*) FROM goods;"

然后是配置文件。找到jdbc.properties或者直接在applicationContext.xml里内联的那段数据源配置,把四个值改成你自己的:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的密码

MySQL 8.0 必须用com.mysql.cj.jdbc.Driver,用老的com.mysql.jdbc.Driver会打印一行警告,某些版本直接抛异常。serverTimezone不配,插入的时间会差 8 小时,订单列表的时间全是错的,这种问题排查起来很费时间,因为它不报错。useSSL=false在本地必须加,否则启动日志里一堆 SSL 警告。

4.3 启动验证:三个必须点开的页面

部署有两种方式:把 war 丢进 Tomcat 的webapps目录启动,或者在 IDEA 里配一个 Tomcat Server 的 Run Configuration。后者调试更方便,改代码后Update classes and resources就能热更。不管哪种,启动后按顺序验证这三个地址:

  • 前台首页http://localhost:8080/项目名/goods/list:能看到商品卡片和分类侧栏,说明数据源、Mapper、视图解析器三者都通了。
  • 后台登录http://localhost:8080/项目名/admin/login:页面样式正常,说明静态资源放行配置生效;用 SQL 里初始化的管理员账号登录,能进后台首页,说明拦截器和角色字段对上了。
  • 完整下单前台加购 → 登录 → 结算 → 提交:订单列表能看到新订单,goods表对应商品stock减一,cart表该用户记录清空,三步全对才算真跑通。

如果首页 404,先看访问路径里有没有带项目名(上下文路径),再看spring-mvc.xml的视图解析器前缀是不是/WEB-INF/jsp/、后缀是不是.jsp。如果首页 500 且日志里是NullPointerException,八成是初始数据没导,分类列表查出来是空,JSP 里遍历时踩了空对象。

4.4 论文与源码对读:把文档里的图变成代码

论文和源码放在一起,最大的作用是给了你一套「设计意图」的说明书。读的顺序建议反过来:先看论文里的功能模块图和 E-R 图,对照 2.3 节的建表语句确认字段,再看时序图,去代码里找对应的 Controller 方法。比如论文里画了「用户下单」的时序,参与者是用户、订单模块、库存模块,你就在OrderServiceImpl.submit里逐行对上,哪一步论文写了但代码没实现,那里就是可以补的改造点——这也是答辩时最容易被追问的地方。

论文里的数据库表设计章节通常还带着字段说明表,这类表格和实际 SQL 常常有出入,比如论文写price是float,代码里是decimal(10,2)。以代码为准,因为跑起来的是代码。反过来,论文里描述的某些约束(比如「订单取消后库存回补」)如果代码里没有,那就是一份现成的 TODO 清单。

5. 避坑与排查:这套项目最容易翻车的五个地方

下面这五条,是我在不同机器上部署这类项目时反复撞见的,几乎每一条都表现为「页面没报错,但结果就是不对」,靠猜很难猜到,按现象对号入座会快很多。

现象一:所有中文全是问号或者乱码。原因通常不是单点,而是三处字符集没对齐——数据库建库时用了utf8而不是utf8mb4,JDBC URL 里没写characterEncoding=utf8,web.xml里CharacterEncodingFilter的forceEncoding没设成 true。三处任缺一处都会乱码。解决方式是从建库语句开始统一到utf8mb4,过滤器配置成<param-name>forceEncoding</param-name><param-value>true</param-value>,并且确认它注册在其他过滤器之前。

现象二:@Transactional明明加了,抛异常后数据还是插进去了。原因有两个常见来源:一是 Spring 与 SpringMVC 的扫描范围重叠,Controller 容器把 Service 也扫了一遍,产生的是没经过 AOP 代理的原始对象,事务注解形同虚设;二是异常被try-catch吞掉了,或者抛的是受检异常而注解没写rollbackFor。解决方式是让applicationContext.xml只扫 Service 及以下、spring-mvc.xml只扫 Controller 包,两边的base-package严格不重叠,并且把rollbackFor = Exception.class补上。

现象三:下单成功,但订单详情页一条商品都没有。现象是订单列表能看到记录和总金额,点进详情空白。原因是orderMapper.insert的 XML 里没有配useGeneratedKeys="true" keyProperty="id",order.getId()拿到的是 null,明细表插进去的order_id全是空值,join 自然查不出来。解决方式是在 insert 标签上补齐这两个属性,同时确认实体类主键字段名和keyProperty完全一致。

现象四:图片上传成功,重启后全部 404。现象是上传当下能看到预览,重新部署 war 包后图片全没了。原因是上传目录写在了项目内的webapp/upload,重新部署会覆盖整个目录。解决方式是把存储路径改成 Tomcat 之外的绝对路径(比如D:/shop-upload/),配置项单独抽出来,再通过<mvc:resources>的file:前缀映射成访问 URL,这样重新部署不影响已上传的文件。

现象五:改了 JSP 或者 Java 代码,刷新页面没变化。现象是 IDEA 里明明改了,浏览器还是旧的。原因有两层:JSP 改动用的是 Tomcat 的 work 目录缓存,Java 改动需要重新编译并热部署。在 IDEA 的 Tomcat 配置里,把On 'Update' action设成Update classes and resources,On frame deactivation同样设置,然后手动点一次 Update。如果还不生效,去 Tomcat 的work/Catalina/localhost/项目名目录下把编译出来的_jsp.java和.class删掉再重启,这是最彻底的一招。

6. 二次开发:把答辩 Demo 改成能交付的系统的四个改造点

这套代码作为一个完整的交易闭环是够用的,但它离「能交付」还差几步。我按投入产出比排了四个改造方向,从最容易见效的开始:

改造点现状改法收益
密码存储MD5 直接加密换成 BCrypt 或 MD5 + 每用户随机盐一处改动,安全性提升明显
订单状态流转只有插入,没有状态机抽OrderStatusService,用枚举约束合法迁移取消订单回补库存有了落点
列表查询性能每次全表查分类分类数据加本地缓存或 Redis首页响应从百毫秒降到十几毫秒
前后端耦合JSP 内嵌 Java 逻辑逐步抽 REST 接口,视图层换静态页为后续接移动端留口子

密码这块先说清楚,因为它牵扯到已有数据。原项目里password字段存的是MD5Util.md5(明文),这个做法在今天的标准下已经不合适了,彩虹表一秒就能反查。改造时不要直接换算法就完事,那会让所有老用户登不进来。可行的做法是加一个兼容层:登录时先按老方式校验,通过后立刻用新算法重算一遍写回库,字段上标记一下版本,叫「登录时渐进升级」。这样老账号在下次登录时自动迁移,用户无感。

public boolean login(String username, String rawPassword) { User user = userMapper.selectByUsername(username); if (user == null) return false; String stored = user.getPassword(); if (stored.startsWith("$2a$")) { return BCrypt.checkpw(rawPassword, stored); // 新格式,直接比对 } // 老格式:MD5 校验通过后立即升级为 BCrypt,下次就走上面的分支 if (stored.equalsIgnoreCase(MD5Util.md5(rawPassword))) { userMapper.updatePassword(user.getId(), BCrypt.hashpw(rawPassword, BCrypt.gensalt())); return true; } return false; }

订单状态机是第二个值得动的地方,因为它直接决定「取消订单后库存回不回来」这件事能不能写干净。原项目里状态字段是个裸的tinyint,到处setStatus(4),改起来没有任何约束。抽成枚举加一个统一的transition(orderId, from, to)方法,把合法迁移写死在代码里,比如只有0 待付款能迁到4 已取消,迁到已取消时在同一事务里调用goodsMapper.addStock回补。做完这一步,以后加「超时自动取消」只需要加一个定时任务,业务逻辑完全复用。

验证改造有没有做对,别靠手点。我的习惯是给核心链路写两三个能跑起来的集成测试,一个测下单扣库存(正常与库存不足各一次),一个测取消订单回补,一个测并发下单不会超卖。并发那个用CountDownLatch起二十个线程打同一件库存为 1 的商品,断言只有一个成功。这类测试写一次能用很久,改完代码跑一遍,比人肉点十遍靠谱得多。

最后说一句血泪经验:动手之前,先把整个项目完整跑通一遍并且跑通一次下单,确认基线是好的,再开始改。从那以后我每次接手这种带论文的毕设源码,都强制自己先跑通一遍主链路并截图留档,改崩了随时能对照回来——不然后面排查问题时,你永远分不清是原项目就有坑,还是自己改出来的。希望帮到你。

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