ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

SSM+JSP打造二手交易平台:并发下单、事务与部署全解析

SSM+JSP打造二手交易平台:并发下单、事务与部署全解析 简介这是一份基于SSMJSPHTML5的二手交易平台网站完整项目面向Java毕业设计、课程设计或期末大作业场景适合有一定Java基础、希望快速搭建可运行项目的新手。压缩包共2000个文件、约53.33MB涵盖119个Java后台类、298个JSP页面、716个JavaScript脚本、290个CSS样式、344张PNG图片以及SQL数据库脚本等前后端代码与配置文件一应俱全。项目采用前端HTML/JSP/Vue、后台SSM框架推荐在IDEA中开发搭配MySQL5.7、Tomcat7/8和Maven部署使用Navicat管理数据系统功能完善界面美观代码含清晰注释关键模块如商品信息管理便于学习与二次开发项目经过严格调试确保可运行。资源同时提供SSM运行视频教学部署过程中遇到问题可向作者咨询帮助使用者快速跑通整个项目。当前已有112人学习下载参考价值和复用性高适合需要完整SSM项目完成毕业设计或课程设计的同学。1. 238 二手交易平台为什么「老技术栈」反而是最容易落地的选择项目包编号里的 238加上附带的源码、数据库、教程说明它是一个典型的课程设计或毕业设计项目。价值不在代码规模而在完整度SSM 负责后端三层JSP 负责服务端渲染HTML5 负责页面结构与交互正好串起一条从浏览器到数据库的全链路。二手交易比图书管理复杂因为它有商品、订单、买卖双方角色和支付状态机足够撑起一场完整答辩。对赶毕设的人这套组合是教室里最容易自证的方案对刚接手老系统的初级工程师SSMJSP 的比例也远比想象中高。交易里真正难写的不是 CRUD而是商品被并发抢走、订单状态错乱、越权看到他人订单这类边界问题下面会针对这些点展开。2. 拆解 SSMJSPHTML5 的请求链路与三层边界2.1 为什么二手交易项目仍然锁定 SSMSSM 是 Spring、SpringMVC、MyBatis 三个框架的组合。在 238 项目里Spring 负责对象管理和事务SpringMVC 负责接收 HTTP 请求并路由到对应方法MyBatis 负责把 Java 对象映射成 SQL 参数、把查询结果映射回对象。三个框架的边界在二手交易项目里分得非常清楚层次框架在交易场景里的职责表现层SpringMVC接收 /goods/publish、/order/create 等请求做参数绑定与校验业务层Spring事务边界、用户会话状态、下单时的防重判断持久层MyBatis商品列表分页 SQL、按状态条件更新订单、数据落库对这个项目来说SSM 比 Spring Boot 更合适的地方在于课程设计环境里 Tomcat、JDK 版本通常偏老SSM 的 XML 配置方式兼容面最宽业务量级也用不上微服务三层直筒式调用反而容易在答辩时讲清楚每个环节。2.2 从 web.xml 看 SpringMVC 的请求入口项目以 WAR 包部署入口在 web.xml。DispatcherServlet 是所有请求的前置控制器。!-- web.xml -- 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 /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping注意这里用的是/而不是/*。用/时.jsp请求不会被 DispatcherServlet 拦截而是直接交给 Tomcat 的 JspServlet 编译用/*时所有请求都会被 SpringMVC 接管JSP 页面会因为找不到对应 Handler 而直接 404。这是我排查了多次「页面打不开」之后最想强调的一条。2.3 JSP 渲染与 HTML5 交互的分工在二手交易平台的页面里JSP 负责「把服务端数据填进页面」HTML5 负责「页面长什么样、浏览器里怎么交互」。JSP 里常见的商品卡片循环!-- goods/list.jsp -- div classgoods-grid c:forEach items${page.list} vargoods div classgoods-card img src${goods.cover} alt${goods.title} classgoods-cover / h3c:out value${goods.title} //h3 span classpricefmt:formatNumber value${goods.price} pattern0.00 //span a href${pageContext.request.contextPath}/goods/detail/${goods.id}查看详情/a /div /c:forEach /div同一页面里的 HTML5 部分用原生 JS 实现交互比如用>script typetext/javascript var orderBtn document.querySelector(.order-btn); orderBtn.addEventListener(click, function () { var goodsId this.getAttribute(data-goods-id); if (confirm(确认下单)) { window.location.href contextPath /order/create?goodsId goodsId; } }); /scriptHTML5 在这个项目里主要集中在语义化标签、>-- 商品表status 控制商品能否被购买 CREATE TABLE goods ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL COMMENT 发布者, title varchar(64) NOT NULL, price decimal(10,2) NOT NULL, cover varchar(255) DEFAULT NULL COMMENT 封面图路径, status tinyint DEFAULT 0 COMMENT 0-在售 1-已售出 2-下架, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_status_created (status, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表status 控制交易走到哪一步 CREATE TABLE orders ( id int NOT NULL AUTO_INCREMENT, goods_id int NOT NULL, buyer_id int NOT NULL, seller_id int NOT NULL, price decimal(10,2) NOT NULL COMMENT 成交价下单时快照, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待付款 1-已付款 2-已发货 3-已完成 4-已取消, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_buyer (buyer_id), KEY idx_seller (seller_id), KEY idx_goods (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;对象状态字段取值含义商品 goodsstatus0 / 1 / 2在售 / 已售出 / 下架订单 ordersstatus0 / 1 / 2 / 3 / 4待付款 / 已付款 / 已发货 / 已完成 / 已取消商品状态和订单状态是两个独立维度。商品状态控制「还能不能买」订单状态控制「交易走到哪一步」。答辩时被问得最频繁的就是这两个状态混在一起代码里务必分开判断。3.2 商品发布封面图上传与路径落库商品发布是典型的表单加文件上传。前端用 HTML5 的multipart/form-data表单提交后端 Controller 接收Controller RequestMapping(/goods) public class GoodsController { PostMapping(/publish) public String publish(GoodsForm form, RequestParam(file) MultipartFile file, HttpSession session) { String username (String) session.getAttribute(LOGIN_USER); if (username null) { return redirect:/login; } User user userService.findByUsername(username); // 1. 保存封面图并拿到可访问的 URL String coverUrl fileStorage.save(file); // 2. 填充商品对象并落库 Goods goods new Goods(); goods.setUserId(user.getId()); goods.setTitle(form.getTitle()); goods.setPrice(form.getPrice()); goods.setDescription(form.getDescription()); goods.setCover(coverUrl); goods.setStatus((byte) 0); goodsService.publish(goods); return redirect:/goods/list; } }fileStorage.save 内部做三件事用 UUID 重命名文件、按日期建目录、写入磁盘后拼接访问路径。封面图不能放在 WEB-INF 下因为浏览器访问不到常见做法是存到项目外的 uploads 目录再用 Tomcat 虚拟目录映射到/uploads/**。注意PostMapping需要 Spring 4.3 以上如果项目用的老版本要改回RequestMapping(value /publish, method RequestMethod.POST)。3.3 下单接口事务、行锁与重复提交下单是二手交易平台里最容易出错的地方典型问题有两个同一件商品被两个人同时下单同一个买家对同一件商品重复点击提交。两个问题都要在数据库层面兜住。Service public class OrderService { Autowired private GoodsMapper goodsMapper; Autowired private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public Long createOrder(Long goodsId, Long buyerId) { // 1. 行级锁锁定商品行防止同一商品被并发下单 Goods goods goodsMapper.selectByIdForUpdate(goodsId); if (goods null || goods.getStatus() ! 0) { throw new BizException(商品不存在或已下架); } if (goods.getUserId().equals(buyerId)) { throw new BizException(不能购买自己发布的商品); } // 2. 幂等判断同一买家对同一商品只能有一个未完成订单 int unfinished orderMapper.countUnfinished(goodsId, buyerId); if (unfinished 0) { throw new BizException(你已下单请勿重复提交); } // 3. 条件 UPDATE 修改商品状态即使锁失效也能兜底 int updated goodsMapper.updateStatus(goodsId, 0, 1); if (updated 0) { throw new BizException(手慢了商品已被拍下); } // 4. 生成订单成交价取商品当前价格的快照 Order order new Order(); order.setGoodsId(goodsId); order.setBuyerId(buyerId); order.setSellerId(goods.getUserId()); order.setPrice(goods.getPrice()); order.setStatus((byte) 0); orderMapper.insert(order); return order.getId(); } }关键参数和逻辑说明selectByIdForUpdate 对应 MyBatis 里的SELECT ... WHERE id ? FOR UPDATE在 InnoDB 下命中主键索引时是行锁两个请求同时进来后一个会等前一个事务提交。updateStatus 用条件 UPDATE 实现 CAS 式状态校验UPDATE goods SET status 1 WHERE id ? AND status 0。即使行锁没起作用这条 UPDATE 返回影响行数为 0 也能拦住并发。countUnfinished 查的是订单表里buyer_id goods_id status IN (0,1,2)的记录数覆盖待付款到已发货的全过程防止重复下单。订单状态转移要固定方向待支付(0) → 已支付(1) → 已发货(2) → 已完成(3)待支付状态下允许取消(4)已支付后再取消需要卖家介入。3.4 商品分页查询的三种写法商品列表是访客第一个看到的页面分页查询在 SSM 里有三种常见写法!-- 第一种手写 LIMIT不依赖任何插件最可控 -- select idselectPage resultTypeGoods SELECT id, title, price, cover, created_at FROM goods WHERE status 0 ORDER BY created_at DESC LIMIT #{offset}, #{pageSize} /select// 第二种PageHelper 物理分页startPage 后面必须紧跟一条 select PageHelper.startPage(pageNum, pageSize); ListGoods list goodsMapper.selectOnSale(); PageInfoGoods pageInfo new PageInfo(list);!-- 第三种手写 COUNT 查询自行封装总页数逻辑 -- select idcountOnSale resultTypeint SELECT COUNT(*) FROM goods WHERE status 0 /selectPageHelper 省事但 startPage 后面只能跟一条查询语句第二次查询会把分页条件串过去这是它最常见的误用。教学答辩场景我更推荐手写 LIMIT可读性好也方便解释 SQL 的执行顺序。提示分页查询里别把 startPage 和 selectByIdForUpdate 放在同一个方法里。FOR UPDATE 的行锁在事务提交前不会释放分页插件生成的 LIMIT 和锁共存时某些 MySQL 版本会直接抛异常。4. 部署到 Tomcat 之前这 5 个配置项必须调代码写完真正让人卡住的往往是部署这一步。238 项目自带数据库和教程但换一台机器、换一个 MySQL 版本下面的配置就要重新过一遍。4.1 JDBC 连接参数与 MySQL 时区# jdbc.properties jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/second_hand?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.password123456MySQL 8.x 必须带 serverTimezone否则启动时报The server time zone value ???÷ is unrecognized。驱动类名也要区分MySQL 8 用 com.mysql.cj.jdbc.DriverMySQL 5.1 用 com.mysql.jdbc.Driver。如果数据库是 5.x 却配了 8.x 驱动大概率启动期才报错编译期完全看不出来。4.2 静态资源路径与视图解析器!-- spring-mvc.xml -- bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/jsp/ / property namesuffix value.jsp / /bean mvc:resources mapping/static/** location/static/ / mvc:annotation-driven /JSP 放在 /WEB-INF 下的好处是浏览器无法直接访问必须经过 Controller 跳转这时直接输 URL 返回 404 反而是一种保护。static 目录如果放进了 /WEB-INFcss/js 会一直 404因为 WEB-INF 对浏览器完全封闭静态资源必须放在 webapp 根目录下并用上面的 mapping 放行。4.3 字符编码过滤器与 Tomcat URIEncoding中文乱码的排查顺序Tomcat 的 connector、Spring 的 CharacterEncodingFilter、MySQL 连接串的 characterEncoding、JSP 的 pageEncoding。最常漏的是 Tomcat 这边。!-- server.xml -- Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8 /Spring 侧的过滤器要放在 web.xml 所有 servlet 之前filter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mappingforceEncodingtrue 表示请求和响应都强制使用 UTF-8。不设这个参数时响应编码可能回退到 ISO-8859-1中文全部变成问号。4.4 文件上传大小限制商品封面图上传Spring 默认的 CommonsMultipartResolver 阈值很小实际使用中要主动调大bean idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namemaxUploadSize value5242880 / property namemaxUploadSizePerFile value2097152 / property namedefaultEncoding valueUTF-8 / /beanmaxUploadSize 是整个请求的总大小上限maxUploadSizePerFile 是单文件上限单位都是字节这里分别对应 5MB 和 2MB。超过限制时 SpringMVC 抛 MaxUploadSizeExceededException需要加一个全局异常处理器把异常转成页面上的一句提示而不是默认的堆栈页。4.5 部署常见报错排查表现象可能原因处理方式访问首页 404项目未部署到 webapps或访问路径缺 contextPath确认部署目录和访问路径是否带项目名检查 DispatcherServlet 的 url-patternJSP 内容以源码形式展示servlet 映射为 /* 拦截了 JspServlet把 url-pattern 改为 / 并确保 JSP 在 WEB-INF 下静态资源 css/js 加载不出来资源路径没带 contextPath或 static 未放行使用 ${pageContext.request.contextPath} 拼前缀确认 mvc:resources 配置连接数据库报错时区、字符集或驱动版本不匹配按 4.1 检查连接串和驱动类名Mapper 报 BindingExceptionXML 没被扫描或 namespace 写错检查 mapperLocations 路径namespace 必须与接口全限定名一致JSP 改了不生效最常见的两个原因一是浏览器缓存命中返回 304二是 Tomcat 的 reloadable 对 JSP 修改感知有延迟。强制刷新时可以带一个时间戳参数/goods/list?t%System.currentTimeMillis()%。5. 让 238 项目再进一步的三个改造点5.1 商品搜索从 LIKE 到全文索引二手商品标题和描述存在 goods 表常见的搜索 SQLSELECT * FROM goods WHERE status 0 AND (title LIKE CONCAT(%, #{keyword}, %) OR description LIKE CONCAT(%, #{keyword}, %))LIKE %keyword%无法走 B 树索引数据到十万级就明显变慢。MySQL 5.7 以上 InnoDB 支持带 ngram 解析器的中文全文索引ALTER TABLE goods ADD FULLTEXT INDEX ft_goods_search (title, description) WITH PARSER ngram;查询改成SELECT id, title, price FROM goods WHERE MATCH(title, description) AGAINST(#{keyword} IN NATURAL LANGUAGE MODE) LIMIT #{offset}, #{pageSize};ngram 默认分词长度为 2单个英文单词如「switch」会索引不上但二手货物标题以中文词组为主影响不大。5.2 用 Redis 缓存热门商品详情列表页每访问一次就查一次数据库加缓存改造量很小public Goods getGoodsDetail(Long goodsId) { String key goods:detail: goodsId; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseObject(cached, Goods.class); } Goods goods goodsMapper.selectByPrimaryKey(goodsId); if (goods ! null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES); } return goods; }缓存更新用「先更新数据库再删除缓存」的策略。商品下架或改价时除了执行 UPDATE还要主动删除goods:detail:{id}这个 key否则页面会展示已下架的商品。5.3 用订单号替代自增 ID 防越权如果订单详情接口是/order/detail/10001遍历 ID 就能看到别人的订单。最简单的修复是在下单时生成一个订单号对外展示内部关联仍用自增主键String orderNo SO System.currentTimeMillis() String.format(%04d, new Random().nextInt(10000));更好的做法是参考 Snowflake 思路用 hutool 的IdUtil.getSnowflakeNextId()生成 19 位数字。改造后 URL 统一暴露 orderNo自增 id 不出现在任何页面和接口参数里。同时订单详情的权限校验不能只靠 URL 上藏一个长参数Service 层必须加归属判断public Order getOrderForUser(String orderNo, Long userId) { Order order orderMapper.selectByOrderNo(orderNo); if (order null) { throw new BizException(订单不存在); } if (!order.getBuyerId().equals(userId) !order.getSellerId().equals(userId)) { throw new BizException(无权查看该订单); } return order; }另外注意URL 换成 orderNo 后列表接口的返回对象也要跟着换字段别只改了详情页列表 JSON 里仍然把 id 暴露给前端那样越权路径依然存在。本文还有配套的精品资源点击获取
返回列表