☰
JSP公路车销售系统实战:数据库建模、事务处理与上传避坑全解析
2026/10/15 4:25:17 网站建设 项目流程

一提到“jsp网上公路车销售系统”,很多人第一反应是“这年头还有人在搞JSP?”——其实真不少。我前段时间就完整做了一套,踩了不少坑,也把很多细节重新捋清楚了。这篇直接把整个系统的设计思路、数据库建模、核心功能链路、后台管理、文件上传以及几个高频坑全拆开讲,适合正在做JSP课程设计、毕业设计,或者想用老技术栈快速搭一套“能跑、能演示、能答辩”的完整项目的人。

我要先把话说在前面:JSP确实老了,但用它做一套B2C电商原型,反而能让你把Servlet、Filter、Session、JDBC事务、JSTL、MVC分层这些底子打得非常扎实。这套公路车销售系统,不是放着好看的演示品,是真能下单、真能支付(模拟)、真能发货的那种完整闭环。下面从选型逻辑开始讲。

1. 为什么选题是“公路车”,为什么技术栈选了JSP+Servlet

1.1 公路车这个品类的特殊性,决定了系统要做哪些事

公路车不是普通的标品。它有两个非常突出的特点:价格高、属性多。一辆入门级公路车也要两三千,碳架整车两三万也不稀奇,所以购买决策周期比买普通运动用品长得多,用户会反复看配置、看几何、看尺码。作为一个销售系统,你得把“车架材质、变速套件等级、轮组类型、车架尺码、刹车方式、整车重量”这些信息清晰展示出来,否则用户没法做决策。

这就决定了系统不能只做一个简单的商品列表。商品详情页要有参数表、图片集合、价格展示、库存状态,还要能按品牌、按价格区间、按变速套件等级筛选。后台则要能维护这些参数,能上下架商品、改库存、处理订单。把这些功能做完,其实已经覆盖了一个标准电商后台80%的要点。

1.2 为什么我依然选择JSP+Servlet,而不是Spring Boot

我做这个选型前,其实认真对比过Spring Boot + Thymeleaf和前后端分离的做法。最后选了最“古董”的组合,理由有三个:

第一,这套系统最核心的价值是教学过程和技术底子。JSP直接暴露了HTTP请求从Servlet到JSP渲染的完整路径,Filter怎么拦截、Session怎么存、JDBC连接怎么管理,每一步都看得见摸得着。Spring Boot把这些都封装掉了,你反而不知道底层发生了什么。

第二,部署环境简单。不需要Maven全家桶,不需要复杂的打包配置,一个Tomcat扔进去就能跑。课程设计和毕业设计场景下,这简直是最大的省心点。

第三,很多老系统的维护需求依然存在。学会JSP意味着你能看懂那些跑了好多年的老项目,这个技能在现实工作中并不冷门。

技术方案上手门槛对Servlet/JDBC底层理解的帮助部署复杂度适合场景
JSP+Servlet+JDBC较低非常直接低,扔Tomcat就能跑课程设计、老项目维护
Spring Boot+Thymeleaf中等较低,框架封装多中,需Maven打包新项目快速开发
前后端分离(Vue+API)较高较低,需懂跨域/鉴权高,需前端部署企业级团队开发

我最终的建议是:如果你是为了学东西,JSP这套方案值得完整做一遍;如果你后续要找工作,再基于这个项目去理解Spring Boot的封装方式也不迟,两者不冲突。

2. 数据库设计是第一道分水岭:公路车的SKU属性必须拆开存

很多JSP课设的数据库就一张商品表加一张订单表,字段一贴就完事。这种设计放到公路车品类上,一定会出问题。公路车一个车系往往有几款配置版本,比如同一款车架,可能有“禧玛诺105变速版本”和“禧玛诺UT变速版本”,价格、重量、库存都不一样。属性不拆开,后台维护就是灾难。

2.1 商品主表和属性表:宁可设计冗余,不要字段堆砌

我设计的核心表是bike(公路车车型表),字段包括:

  • id:主键
  • bike_name:车型名称
  • brand_id:关联品牌表
  • model_year:年款
  • frame_material:车架材质(铝架/碳架/钢架)
  • groupset:变速套件等级
  • wheel_size:轮径(700c/650b)
  • brake_type:刹车方式(圈刹/碟刹)
  • price:价格
  • stock:库存
  • status:上下架状态(0下架1上架)
  • create_time:创建时间

这里的关键点在于,我没有把“变速器品牌”“飞轮齿比”“车架重量”“建议身高”这些全部做成字段,而是留了一个bike_attr钩子表做属性扩展。原因很简单:公路车的参数变化太频繁,每年新款都可能加新参数,改表结构成本高,用键值对结构灵活得多。

CREATE TABLE bike ( id INT PRIMARY KEY AUTO_INCREMENT, bike_name VARCHAR(120) NOT NULL, brand_id INT NOT NULL, model_year INT DEFAULT 2024, frame_material VARCHAR(30), groupset VARCHAR(50), wheel_size VARCHAR(20), brake_type VARCHAR(20), price DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE bike_attr ( id INT PRIMARY KEY AUTO_INCREMENT, bike_id INT NOT NULL, attr_name VARCHAR(50) NOT NULL, attr_value VARCHAR(200) NOT NULL );

有的同学问:weight、height_range这些字段直接加在bike表里不行吗?也行,但你要知道,参数的增减是不可控的。用到最后,bike表会有二三十个字段,一半都是空的,页面渲染时还要写死一套if判断,很蠢。属性表看起来多了一张表,实际维护成本低很多。展示的时候用一次循环,把相同bike_id的属性全部拉出来,比你写几十个字段取别名灵活多了。

2.2 订单相关表:从订单主表到订单明细的强一致关系

订单设计我给了两张表:orders和order_item。orders存放一笔订单的总体信息,order_item存放每一件商品的快照信息。

这里特别要注意“快照”两个字。我在设计时故意把商品名称、价格都冗余到了order_item里,而不去关联bike表。为什么?因为公路车的价格和配置随时会变,用户在购买时的价格必须以当时的记录为准,否则过几个月查订单,商品改价了,历史订单就对不上账了。

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_name VARCHAR(30) NOT NULL, receiver_phone VARCHAR(20) NOT NULL, receiver_address VARCHAR(200) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, bike_id INT NOT NULL, bike_name VARCHAR(120) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL );

订单状态我用了五个数字表示,这是整个系统里很重要的一个设计。页面和后台都只认这个数字,不认中文,需要用JSTL或后台枚举来转换展示名。状态流转逻辑是固定的:待付款可以取消,待付款点击支付变成已付款,已付款后台发货变成已发货,已发货确认收货变成已完成。这个状态机保证了前后台操作的边界,后台不能把已取消的订单直接改成已发货。

2.3 品牌、用户、购物车,这几个表的取舍

品牌表很简单,id+name+logo_url,就不贴结构了。用户表除了id、username、password、phone,我加了create_time,这是方便后台做用户列表排序。购物车我建议用Session实现,不建表。有些同学会把购物车建到数据库里,理由是刷新不丢,但登录用户才能用,未登录的浏览用户反而没有购物车,体验很差。课程设计阶段,Session级购物车完全够用,你要做的只是把购物车的数据结构设计好。

3. 商品展示到提交订单:核心链路的完整拆解

3.1 首页与列表页:分类导航、筛选、排序和MySQL分页

首页我用的是一个index.jsp,上半部分是顶部导航和轮播图,下半部分是“精品推荐”和“全部车型”两个区域。精品推荐我直接从bike表里查库存大于0且状态为1的前6条数据,没有做复杂的推荐算法。列表页按品牌、变速套件等级、价格区间筛选,SQL是在dao层动态拼接的。

分页这里我强烈建议自己手写,不要一上来就引入PageHelper之类的插件。手写分页你才能真正理解LIMIT offset, size的用法。我的做法是:

  • 前端传page(当前页码,默认1)和pageSize(每页条数,默认8)
  • DAO层查出总记录数,算出总页数
  • 查询当前页数据:LIMIT (page-1)*pageSize, pageSize
  • 把数据List、当前页、总页数封装到PageBean对象里,放进request域
<c:forEach items="${pageBean.list}" var="bike"> <div class="bike-card"> <img src="${bike.picUrl}" alt="${bike.bikeName}"/> <h3>${bike.bikeName}</h3> <p>${bike.groupset}</p> <span class="price">¥${bike.price}</span> </div> </c:forEach> 页码区域: <a href="bikeList?page=${pageBean.page-1}" <c:if test="${pageBean.page==1}">class="disabled"</c:if>>上一页</a> <span>${pageBean.page}/${pageBean.totalPage}</span> <a href="bikeList?page=${pageBean.page+1}" <c:if test="${pageBean.page==pageBean.totalPage}">class="disabled"</c:if>>下一页</a>

这里有个细节很多人会忽略:总页数计算。总记录数是15,每页8条,总页数应该是2。用(totalCount + pageSize - 1) / pageSize来算,而不是直接整除。直接整除去掉余数,最后一页数据就永远显示不出来。

3.2 Session级购物车的实现与取舍

购物车在JSP项目里最经典的实现是Session里放一个Map。键是商品id,值是一个CartItem对象,里面封装bikeId、bikeName、price、quantity这几个字段。这样做的好处是:用户不登录也能加购物车,结账时才要求登录,转化路径更顺畅;坏处是Session过期购物车就没了,但这本来就是一个演示项目的合理取舍,不需要太重。

前面加购物车的Servlet:

HttpSession session = request.getSession(); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<Integer, CartItem>(); } int bikeId = Integer.parseInt(request.getParameter("bikeId")); BikeDao bikeDao = new BikeDao(); Bike bike = bikeDao.findById(bikeId); if (cart.containsKey(bikeId)) { CartItem item = cart.get(bikeId); item.setQuantity(item.getQuantity() + 1); } else { CartItem item = new CartItem(bike.getId(), bike.getBikeName(), bike.getPrice(), 1); cart.put(bikeId, item); } session.setAttribute("cart", cart);

CartItem用Integer做键存在一个经典陷阱:Integer缓存范围是-128到127,超出范围时Integer.valueOf()会创建新对象。如果你不小心用了两个新的Integer对象做键却不重写equals和hashCode,就会拿不到购物车里的数据。实际开发中,我建议大家直接用int做参数,让HashMap自动装箱,并且CartItem里的bikeId字段保持简单类型,避免踩这个坑。

3.3 提交订单:一次事务要保护哪几张表

提交订单是整套系统里最需要认真写的环节,因为它涉及多表一致性。一个完整的提交订单操作,至少要执行三步:

  1. 往orders表插入一条订单主记录
  2. 循环往order_item表插入订单明细
  3. 把bike表的库存减掉对应数量
  4. 清空Session里的购物车

如果中间任何一步失败,前面插入的订单和明细就都白写了,会出现“订单主表有了,明细没有”的脏数据。解决办法是JDBC事务:关闭自动提交,所有操作成功再commit,任何异常直接rollback。

Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 插入订单主表 OrderDao orderDao = new OrderDao(); int orderId = orderDao.insert(conn, order); // 循环插入订单明细 for (CartItem item : cart.values()) { orderDao.insertItem(conn, orderId, item); } // 扣减库存 bikeDao.decreaseStock(conn, item.getBikeId(), item.getQuantity()); conn.commit(); } catch (Exception e) { if (conn != null) { conn.rollback(); } e.printStackTrace(); throw new RuntimeException("提交订单失败"); } finally { DBUtil.closeAll(conn, null, null); }

注意一个很小的细节:很多JSP项目里,Dao层每个方法都自己获取连接、释放连接,这个写法在单表操作时没问题,但遇到这种跨表事务就只能全部重写。所以我在设计这个功能时,让insert、insertItem、decreaseStock这些方法都接收外部传入的conn,由Service层统一管理连接和事务。这个思想,算是我建议你的“事务边界放在Service层”的完整产物。

4. 后台管理与文件上传:比想象中麻烦的部分

4.1 后台模块划分:商品、订单、用户三个大头

后台管理页面我用了独立的admin文件夹,用一个admin_login.jsp做登录入口,登录成功后把管理员账号放进Session。后台的拦截逻辑不要写在每个管理员Servlet里,那会重到怀疑人生,应该用一个Filter统一拦一下:

<filter> <filter-name>AdminAuthFilter</filter-name> <filter-class>com.shop.filter.AdminAuthFilter</filter-class> </filter> <filter-mapping> <filter-name>AdminAuthFilter</filter-name> <url-pattern>/admin/*</url-pattern> </filter-mapping>
public void doFilter(...) { HttpSession session = req.getSession(); Object admin = session.getAttribute("admin"); if (admin == null) { resp.sendRedirect("admin_login.jsp"); return; } chain.doFilter(req, resp); }

后台的商品管理无非增删改查,但有两个点必须写清楚:图片上传和库存修改。库存修改时,我做了个简单的乐观锁处理:每次更新库存的时候带上原来查到的版本号,如果更新影响行数为0就说明被并发修改了,提示用户刷新重试。这个功能在课程设计里属于加分项,但实际写起来也就几行SQL的事。

4.2 图片上传的路径问题,和避免重启丢文件的两种方案

JSP上传图片,如果用Servlet 3.0的@MultipartConfig,代码本身不复杂,真正容易出问题的是图片保存路径。很多教程让你把图片存到项目根目录的upload文件夹下,这在启动时只是临时解压的目录,Tomcat重启后可能被清空,导致已上传的图片全变成404。

我试过几种方案,最后稳定可行的是这样:图片存到服务器本地固定目录,比如/webdata/upload,然后给Tomcat配置虚拟路径映射,让浏览器通过http://localhost:8080/upload/xxx.jpg访问。

在Spring Boot里配置虚拟映射很简单,但在原生的JSP里,Java代码也可以做到类似效果:

String uploadPath = "C:/webdata/upload/"; // 按实际环境修改 String fileName = UUID.randomUUID().toString().replace("-", "") + suffix; bike.getPart("pic").write(uploadPath + fileName);

这里最关键的避坑点是:文件名一定不能用用户上传的原始文件名,否则两个用户上传“车.jpg”就会相互覆盖。用UUID重命名是最省心的做法。展示图片时,页面里的img标签src直接写成/upload/重命名后的文件名,再配合Tomcat的虚拟路径映射,就完全没问题了。

5. 用JSP做公路车销售系统,绕不开的五个坑

5.1 中文乱码:Filter设置字符集的顺序问题

JSP+Servlet做中文项目,乱码是必然遇到的。乱码有三个入口:请求参数乱码、响应输出乱码、数据库存取乱码。推荐用一个Filter统一处理,我习惯在web.xml里配置:CharacterEncodingFilter,设置encoding为UTF-8,并且forceEncoding为true,这样请求和响应都会被处理。

但有个容易忽略的小坑:如果你的web.xml里Filter映射顺序不对,或者JSP页面里没有指定pageEncoding="UTF-8",Filter再怎么写也白搭。页面顶部的<%@ page contentType="text/html;charset=UTF-8" language="java" %>这几个字,一定要行行落实到位。

5.2 金额精度:double是魔鬼

公路车整车价格动辄几千上万,算总价时如果数据库字段是double,总金额在页面上显示成“19999.999999”就很尴尬。我全套代码用的是BigDecimal和DECIMAL(10,2)。购物车结算时用BigDecimal的add方法累加,禁止用double直接相乘。这个问题在查看订单报表时尤其明显,精度不处理好,对账就对不上。

5.3 JSTL 标签库版本与新容器的兼容

JSTL在JSP项目里几乎是必需品,但很多人会遇到“明明写了<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>,页面却报错找不到标签库”的情况。这多半是引的JSTL jar包太老。Tomcat 8及以上建议用JSTL 1.2,它会自动识别旧版URI。把这些jar放进WEB-INF/lib后最好clean一下,再用Maven的同学建议直接引入jstl依赖,版本1.2就能解。

5.4 SQL注入与PreparedStatement

JSP项目因为直接手写SQL,注入风险最大。商品筛选这种动态条件SQL,只要用户在前端地址栏塞一个or 1=1,很可能就把整个表数据全捞出来。我的原则是所有SQL一律用PreparedStatement,参数用?占位,禁止用字符串拼接。哪怕筛选条件为空,也只拼条件片段,不拼用户输入值。这一条必须写进你的代码规范里。

5.5 上传图片404与Tomcat重启丢文件

这个问题就是我在第4节提到的图片路径问题。很多同学做完上传发现图片能存,但页面访问404,通常有两个原因:一是当前IDE里部署的是解压项目,webapps实际目录和你看到的不一致;二是没有配虚拟路径映射。我的建议是在本地用一个固定的外部目录存储图片,并把存储路径写到配置文件里,不要说改就改。还有一个附加问题:商品已删除但图片文件还留着,长期运行会积累垃圾文件,这个虽然不影响功能,但你的项目里最好加一个“删除商品时一并删除图片文件”的逻辑,这在答辩时是个很不错的细节亮点。

我做完这套系统后最大的体会是:JSP项目的难点不在JSP标签本身,而在于如何在一个没有框架辅助的环境里,把分层、事务、路径、编码这些基础问题都处理干净。这些经验一旦沉淀下来,后面学什么框架都顺。如果你动手做的时候碰到和我一样的问题,或者有自己更顺手的解法,评论区聊一聊,互相抄抄作业也是好事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询