资讯动态

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

发布时间:2026/10/27 10:37:19 来源:尧图企业网站定制
简介这份网上购物系统资料包面向课程设计与毕业设计场景围绕基于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商品列表、详情、购物车、订单页的服务端渲染与局部异步控制层SpringMVCController接收表单、参数校验、跳转与 JSON 返回业务层SpringService 声明式事务下单、扣库存、购物车合并等跨表逻辑持久层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 的SqlSessionFactoryspring-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); ListGoods goodsList goodsService.query(keyword, categoryId, minPrice, maxPrice, sort); PageInfoGoods 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 商品表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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;三个设计点值得单独拎出来讲。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 idquery resultTypecom.xxx.entity.Goods SELECT id, name, category_id, price, stock, image, sales, status FROM goods where status 1 !-- 前台只查上架商品下架的不露头 -- if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if /where !-- 排序用白名单映射禁止 ${sort} 直拼 -- choose when testsort priceAscORDER BY price ASC/when when testsort priceDescORDER BY price DESC/when when testsort salesDescORDER BY sales DESC/when otherwiseORDER BY id DESC/otherwise /choose /selectwhere标签会自动处理开头的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 传个-1PageHelper 会抛出让人一头雾水的偏移量异常。分页本身靠 PageHelper 的startPage加PageInfo但有个细节必须注意PageHelper.startPage()只对紧随其后的第一条查询生效。如果你在它和真正的查询之间插了一句count或者查分类的 SQL分页就会作用到错误的对象上PageInfo里的total也会变成一个荒唐的数字。我在这种地方的习惯是把startPage写在 Service 方法的第一行紧接着就是 Mapper 调用中间不留任何空隙。3.2 购物车未登录走 Session登录后合并入库购物车是这套系统里状态最纠结的模块。用户没登录也要能加商品登录之后这些商品还得跟账号里的购物车合到一起。这类项目普遍采用双写方案未登录时把MapInteger, Integer商品 ID 到数量塞进 Session登录成功后一次性合并进cart表并清空 Session。Service public class CartServiceImpl implements CartService { Autowired private CartMapper cartMapper; /** * 登录成功后调用把 Session 里的临时购物车合并进数据库 * param userId 当前登录用户 * param session 未登录期间累积的购物车key 为 goodsIdvalue 为数量 */ Override Transactional(rollbackFor Exception.class) public void mergeSessionCart(Integer userId, HttpSession session) { MapInteger, Integer tempCart (MapInteger, Integer) session.getAttribute(tempCart); if (tempCart null || tempCart.isEmpty()) { return; // 没有临时数据直接返回避免无谓的事务开销 } for (Map.EntryInteger, 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.classSpring 默认只对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, ListCart cartList, String receiver, String phone, String address) { if (cartList null || cartList.isEmpty()) { throw new RuntimeException(购物车为空无法提交订单); } BigDecimal total BigDecimal.ZERO; ListOrderItem 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 idreduceStock 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 里漏了useGeneratedKeystrue keyPropertyid明细表插进去的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/** locationfile:${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.8MySQL 驱动的版本号以及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.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password你的密码MySQL 8.0 必须用com.mysql.cj.jdbc.Driver用老的com.mysql.jdbc.Driver会打印一行警告某些版本直接抛异常。serverTimezone不配插入的时间会差 8 小时订单列表的时间全是错的这种问题排查起来很费时间因为它不报错。useSSLfalse在本地必须加否则启动日志里一堆 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而不是utf8mb4JDBC URL 里没写characterEncodingutf8web.xml里CharacterEncodingFilter的forceEncoding没设成 true。三处任缺一处都会乱码。解决方式是从建库语句开始统一到utf8mb4过滤器配置成param-nameforceEncoding/param-nameparam-valuetrue/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 里没有配useGeneratedKeystrue keyPropertyidorder.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 resourcesOn 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 的商品断言只有一个成功。这类测试写一次能用很久改完代码跑一遍比人肉点十遍靠谱得多。最后说一句血泪经验动手之前先把整个项目完整跑通一遍并且跑通一次下单确认基线是好的再开始改。从那以后我每次接手这种带论文的毕设源码都强制自己先跑通一遍主链路并截图留档改崩了随时能对照回来——不然后面排查问题时你永远分不清是原项目就有坑还是自己改出来的。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑