SSM+JSP+HTML5二手交易系统拆解:从依赖配置到部署优化
简介基于SSM与JSP技术的二手交易平台网站项目是一份适合毕业设计、课程设计及期末大作业的完整JavaWeb源码包面向需要快速掌握SSM框架的在校生和初级开发者。压缩包共2000个文件整体约53.33MB包含716个JS脚本、298个JSP页面、290个CSS样式、119个Java类以及117个XML配置文件前后端源码、数据库脚本、开发工具均已整理齐全。系统功能覆盖商品信息展示、交易管理、后台操作等常见模块界面简洁、交互流畅代码注释清晰新手可按模块逐步阅读项目结构层次分明便于理解SSM框架的MVC分层思想。部署时建议使用MySQL 5.7和Tomcat 7.x或8.x项目已经过严格调试并附有运行视频教学与部署答疑支持。已有112人参与学习下载后可快速参考部署适合作为高分开题或课程设计的参考项目蓝本。1. 一套 SSMJSPHTML5 的二手商品交易系统到底值不值得拆拆这套基于 SSM JSP HTML5 的二手交易平台时我先把“238”这个编号忽略掉——它更像是课程设计里的项目代号真正决定复现难度的是三个框架的版本协同、JSP 在 WEB-INF 下的路径规则以及 HTML5 本地存储与后端接口的职责切分。这套系统的价值在于它完整保留了 Java Web 单体时代的交往模式Spring 管 BeanSpringMVC 管路由MyBatis 管 SQLJSP 负责服务端渲染HTML5 只在交互层补充体验。如果你正在准备 Java 课程设计或者需要把一个老项目改成可运行系统这篇正文能帮你把启动顺序、事务边界和部署参数一次理清。2. 从空项目到 SSM 容器Spring、SpringMVC、MyBatis 三条线的缝合法2.1 先把依赖钉死再谈代码搭建 SSM 二手交易系统最容易翻车的是依赖版本互相打架。我一般按“Servlet 容器 → Spring → MyBatis → JSON 库”的顺序锁版本。以 Tomcat 8.5 JDK 8 为例Maven 里的关键依赖是这样一组properties spring.version5.1.18.RELEASE/spring.version mybatis.version3.5.6/mybatis.version /properties dependencies !-- Spring MVC 与上下文必须同版本否则容器会报 NoSuchMethodError -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- MyBatis 官方 Spring 整合包版本要和 mybatis 对齐 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency !-- MySQL 驱动5.x 驱动类名是 com.mysql.jdbc.Driver -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version scoperuntime/scope /dependency !-- JSP 标签库与 APIjsp-api 必须 provided避免和 Tomcat 内置类冲突 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjsp-api/artifactId version2.2/version scopeprovided/scope /dependency /dependencies为什么要把版本单独抽成 properties因为 Spring 的 webmvc、context、tx 必须同版本MyBatis 3.5.6 与 mybatis-spring 2.0.6 的搭配针对 Spring 5.1 做过官方测试。MySQL 驱动选 5.1.49 是为了兼容老项目常用的连接串如果数据库是 8.0就把驱动换成 8.0 系列并把 className 改成com.mysql.cj.jdbc.Driver。jstl 1.2 和 jsp-api 2.2 一个放 classpath、一个用 provided是为了避免 Tomcat 9 下 jar 冲突出现Unable to compile class for JSP的报错。2.2 web.xml 中的加载顺序决定 DispatcherServlet 会不会吞掉 JSPSSM 的入口仍然是 web.xml但不同配置会让行为差很多。二手交易这类带大量 JSP 页面的项目我建议按下面这种结构写web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd version3.1 !-- 先创建根容器负责 service、dao、数据源等 -- listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-context.xml/param-value /context-param !-- 再创建 SpringMVC 子容器负责 controller 与视图解析 -- servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/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 !-- 必须放在 DispatcherServlet 之后且映射到 /*否则 POST 提交中文会乱码 -- 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-mapping /web-app这里的顺序很关键ContextLoaderListener先创建根容器DispatcherServlet再创建子容器子容器能访问父容器的 Bean但父容器看不到子容器。如果url-pattern写成*.doJSP 页面里的静态资源路径会好理解但 HTML5 页面想用干净的/item/123路由就不方便所以这里用/配合mvc:default-servlet-handler/放行静态资源。配置项位置作用context-paramweb.xml指定根容器配置文件路径ContextLoaderListenerweb.xml创建 service/dao 所在的 Spring 容器DispatcherServletweb.xml创建 controller 所在子容器InternalResourceViewResolverspring-mvc.xml决定 JSP 前缀后缀2.3 Controller 和 Service 分两个容器扫事务代理才安全很多课程设计的 SSM 项目把context:component-scan base-packagecom.shop/同时写在两个配置里结果出现同一个 Service 被两个容器各实例化一次。SpringMVC 子容器会优先用自己容器里的 Service而这个 Service 没有经过 AOP 代理Transactional直接失效。正确做法是在根容器扫排除 Controller!-- spring-context.xml -- context:component-scan base-packagecom.shop context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean iddataSource classorg.apache.commons.dbcp.BasicDataSource property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/second_hand?useUnicodetrueamp;characterEncodingUTF-8/ property nameusername valueroot/ property namepassword valueroot/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nametypeAliasesPackage valuecom.shop.entity/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean mybatis:scan base-packagecom.shop.mapper/子容器只扫 Controller!-- spring-mvc.xml -- mvc:annotation-driven/ mvc:default-servlet-handler/ context:component-scan base-packagecom.shop.controller use-default-filtersfalse context:include-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean idviewResolver classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/jsp// property namesuffix value.jsp/ /bean分完容器后JSP 页面要放在webapp/WEB-INF/jsp下用户直接访问该路径会被容器拒绝只能通过 controller 里return item_list转发到/WEB-INF/jsp/item_list.jsp。这样既保住了 JSP 服务端渲染能力又避免了和 HTML5 静态资源混在一起。提示如果项目在 IDEA 中直接启动Artifact 类型一定要选 war exploded否则 JSP 修改后要重启才会生效。3. 二手交易数据模型与 MyBatis 持久层从表结构到动态 SQL3.1 五张表足够撑起一个交易闭环把这套系统的业务收拢一下核心数据模型可以压缩成五张表用户、商品、分类、收藏、订单。二手交易的特点是商品状态会从“在售”流转到“已卖出”因此设计表结构时要特意留状态字段而不是在商品被打下架后直接删除记录。表名职责关键字段user用户注册与登录id, username, password_hash, phoneitem商品发布与检索id, seller_id, category_id, title, price, stock, statuscategory商品分类树id, name, parent_idcollect收藏关系id, user_id, item_id, create_timeorder_info交易订单id, buyer_id, item_id, amount, status以下是一张可直接建的表字段注释尽量写全后面 mapper 写动态 SQL 时就不用来回翻表结构CREATE TABLE item ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, seller_id INT UNSIGNED NOT NULL COMMENT 卖家用户ID, category_id INT UNSIGNED NOT NULL COMMENT 商品分类ID, title VARCHAR(120) NOT NULL COMMENT 商品标题, price DECIMAL(10,2) NOT NULL COMMENT 出售价格, stock INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 可卖数量二手商品通常为1, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-在售 1-下架 2-已卖出, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_seller (seller_id), KEY idx_category_status (category_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;价格必须用DECIMAL(10,2)不能用 FLOAT。float 是近似值订单金额用浮点会出现 19.99 变成 19.989999 的金额误差对账时很难解释。状态字段用 TINYINT 而不是 VARCHAR是为了让商品列表查询能用组合索引idx_category_status把分类过滤和状态过滤放在同一个索引里。create_time使用DATETIME而不是TIMESTAMP避免 2038 年问题也方便前端直接用字符串展示。3.2 Mapper 接口与 XML 分离查询条件怎么拼才安全二手商品列表的搜索条件通常包含标题关键字、分类、最低价、最高价、状态和分页。如果把这么多条件全塞进 Controller 再传给 MapperSQL 会因为AND和WHERE的位置问题反复报错。常见做法是定义一个ItemQuery查询对象只放查询字段public class ItemQuery { private String title; private Integer categoryId; private BigDecimal minPrice; private BigDecimal maxPrice; private Integer status; private Integer offset; private Integer pageSize; // getter/setter 省略 }接口定义public interface ItemMapper { ListItem selectByCondition(Param(condition) ItemQuery query); int insertItem(Item item); int deductStock(Param(id) Integer id); }对应 XML 里用动态 SQL 拼接select idselectByCondition resultTypecom.shop.entity.Item SELECT id, seller_id, category_id, title, price, stock, status, create_time FROM item where if testcondition.title ! null and condition.title ! AND title LIKE CONCAT(%, #{condition.title}, %) /if if testcondition.categoryId ! null AND category_id #{condition.categoryId} /if if testcondition.minPrice ! null AND price gt; #{condition.minPrice} /if if testcondition.maxPrice ! null AND price lt; #{condition.maxPrice} /if if testcondition.status ! null AND status #{condition.status} /if /where ORDER BY id DESC LIMIT #{condition.offset}, #{condition.pageSize} /selectwhere标签会自动去掉第一个AND所以每个if判断里的条件都要写AND这一步是复现时最容易抄错的地方。LIKE CONCAT(%, #{title}, %)把百分号拼进参数里而不是用${condition.title}直接拼接原因是#{}会走 PreparedStatement 参数占位从根上避免 SQL 注入${}只适合表名、排序字段这类固定白名单位置绝不能用于用户输入。3.3 库存扣减与事务边界谁的 Service 该加注解二手交易和普通电商不同多数商品库存只有 1但仍然会出现两个人同时下单的情况。如果不做控制超卖后订单表里会出现两个买家都买同一件在售商品。常见做法是事务加条件更新Service 层这样写Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private ItemMapper itemMapper; Transactional(rollbackFor Exception.class) public Integer createOrder(Order order) { int updated itemMapper.deductStock(order.getItemId()); if (updated 0) { throw new BizException(商品已下架或库存不足); } orderMapper.insertOrder(order); return order.getId(); } }deductStock的 SQL 是UPDATE item SET stock stock - 1 WHERE id #{id} AND stock 0。影响行数为 0 表示库存不足直接抛业务异常让事务整体回滚。这种方法比先 SELECT 再 UPDATE 更稳避免两个请求同时把 stock 读到 1后一个提交时覆盖前一个的扣减。Transactional放在 Service 而不是 Controller是因为事务要同时覆盖deductStock和insertOrder两个 Mapper 方法的调用范围。如果在 Controller 层加注解代理对象无效异常不会触发回滚。注意MyBatis 的if只做动态拼接不做类型转换日期范围需要现在 Service 里把 Date 转成字符串再传入。4. JSP 页面与 HTML5 交互商品搜索、发布表单和本地缓存4.1 JSP 的标签循环与 EL 取值SSM 项目里 JSP 负责服务端渲染商品列表页的数据在 Controller 存入Model然后由 JSP 用 JSTL 标签遍历输出% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % c:forEach varitem items${page.list} varStatusvs div classcard>form idpublishForm action/item methodpost input typetext nametitle required minlength4 maxlength120 placeholder商品名称 input typenumber nameprice required min0.01 step0.01 placeholder价格 input typedate nameexpireDate input typeurl nameimageUrl placeholder图片地址 input typetext listcategories namecategoryText datalist idcategories option value1手机数码/option option value2家具家电/option option value3图书教材/option /datalist button typesubmit发布/button /formtypenumber配合min和step0.01能限制用户输入三位小数typedate在 Chrome、Edge 上会弹出日历但 Firefox 桌面端会退化为普通文本框所以后端要允许非标准格式并做转换。datalist看起来像下拉框但用户仍然能自由输入服务端必须判断传入的分类 ID 是否存在于 category 表否则会出现孤立分类。HTML5元素前端作用服务端校验重点typenumber限制数字格式用 BigDecimal 接收不能以 String 存价格typedate日期选择处理空值格式化 yyyy-MM-ddtypeurlURL 格式校验防止javascript:alert(1)注入datalist输入提示分类 ID 必须存在4.3 用 localStorage 做浏览历史把“最近看过”放在前端JSP 页面每次跳转会刷新整页要记录用户看过哪些商品最简单的方法不是在后端建浏览表而是用 HTML5 的 localStorage 在浏览器本地存商品 ID。这段代码可以放在详情页底部function saveHistory(itemId) { const key used_item_history; let history []; try { history JSON.parse(localStorage.getItem(key)) || []; } catch (e) { history []; } history [itemId].concat(history.filter(id id ! itemId)).slice(0, 10); localStorage.setItem(key, JSON.stringify(history)); } // 从 URL 取商品 ID例如 /item/123 const match location.pathname.match(/\/item\/(\d)/); if (match) { saveHistory(parseInt(match[1], 10)); }这段代码做了三件容易被忽略的事JSON.parse 包了 try/catch避免同一域名下其他页面写入脏数据导致脚本中断filter(id id ! itemId)先去重再用concat把当前商品放到最前面slice(0, 10)只保留 10 条防止 localStorage 被无限撑大。浏览历史只存 ID不存商品标题和价格因为二手商品价格会变存快照反而会让页面显示过期数据。展示历史时再调用批量接口async function renderHistory() { const key used_item_history; let ids []; try { ids JSON.parse(localStorage.getItem(key)) || []; } catch (e) { return; } if (ids.length 0) return; const params new URLSearchParams(); ids.forEach(id params.append(id, id)); const resp await fetch(/item/batch? params.toString()); const result await resp.json(); // result.data 是商品列表按 ids 顺序重新排列 }URLSearchParams会把数组编码成id1id2的格式后端可以用RequestParam(id) ListInteger ids接收。前端把商品 ID 传给服务端拿数据比直接存储整个商品对象更安全也更容易清理。4.4 用 fetch 提交表单解析统一 JSONJSP 页面里用原生 HTML5 fetch 也能做无刷新提交关键是后端返回的 JSON 格式要统一const form document.getElementById(publishForm); form.addEventListener(submit, async (e) { e.preventDefault(); const body new FormData(form); const resp await fetch(form.action, { method: POST, body: body }); if (!resp.ok) { alert(请求失败稍后再试); return; } const result await resp.json(); if (result.code 0) { location.href /item/ result.data.id; } else { alert(result.message); } });这里先检查resp.ok再解析 JSON否则后端返回 500 错误页时响应体是 HTMLresp.json()会报Unexpected token 这种让人摸不着头脑的错误。FormData自动把表单里的 name 组装成参数SpringMVC 用RequestParam或者直接让Item对象的字段名与 name 一致就能完成绑定。提示使用 localStorage 记录浏览历史前先在页面里做一次 JSON.parse 的 try/catch避免脏数据让整个脚本失效。5. 部署到 Tomcat 之后连接池参数、日志定位和页面缓存优化5.1 打包后先看启动日志里的 Deployment 字样本地开发通过 IDEA 跑 Spring Boot 的人第一次部署这种 war 包项目往往会卡在“Tomcat 明明启动了但页面 404”。正确流程是mvn clean package -DskipTests cp target/shop-web.war $CATALINA_HOME/webapps/ cd $CATALINA_HOME/bin ./startup.sh tail -f $CATALINA_HOME/logs/catalina.out日志里出现Deployment of web application archive [shop-web.war] has finished才代表容器成功识别应用。如果没有这一行优先检查web.xml里web-app的版本声名Servlet 3.1 规范在 Tomcat 8.5 下使用absolute-ordering/可能会跳过部分注解扫描需要在web.xml里显式声明需要的 jar。5.2 Druid 连接池的参数别照抄项目数据库连接池如果换成 Druid很多课程设计喜欢把网上的参数整段复制结果maxWait设成600000一个查询卡了会话十秒才报错。二手交易平台这种中小流量系统合理初始参数是bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/second_hand?useUnicodetrueamp;characterEncodingUTF-8/ property nameusername valueroot/ property namepassword valueroot/ property nameinitialSize value5/ property nameminIdle value5/ property namemaxActive value20/ property namemaxWait value3000/ property namevalidationQuery valueSELECT 1/ property nametestWhileIdle valuetrue/ /beaninitialSize代表启动时建立 5 个连接maxActive20要结合 MySQL 的max_connections一起看。Linux 下登录 MySQL 执行SHOW VARIABLES LIKE max_connections;如果数据库限制 100多个应用共享时连接池 maxActive 就不能都设成 50。maxWait3000表示拿连接等待 3 秒超过就抛异常配合日志能快速定位是连接用完还是 SQL 卡住。5.3 JSP 首次访问慢预编译和缓存头JSP 第一次被访问时才编译成 classTomcat 低配置服务器上首次请求可能耗时几百毫秒。如果不想等运行时编译可以在部署前用 Jasper 预编译java -cp $CATALINA_HOME/lib/*:target/classes org.apache.jasper.JspC \ -uriroot src/main/webapp \ -webxml src/main/webapp/WEB-INF/generated_web.xml \ -d target/jspc预编译完成后把生成的 class 文件放到WEB-INF/classes再启动 TomcatJSP 首请求时间会明显下降。另一个容易忽略的优化是给静态资源加缓存头HTML5 的 CSS、JS 文件可以在 Filter 里统一设置response.setHeader(Cache-Control, public, max-age86400);普通商品详情页不要设置长缓存因为价格和库存会变图片和 CSS 设置为 86400 秒再次访问时浏览器会直接命中本地缓存Redis 都不用引入。做完这步后打开 Chrome DevTools 的 Network 面板对比商品列表页和静态资源请求的时间差能直接看到优化前后的差异。本文还有配套的精品资源点击获取