说到JSP网上公路车销售系统你第一反应可能是“又是老套的Java课程设计”。但真正把这类系统做完你会发现一套公路车电商系统背后涉及的需求拆解、表结构设计和并发扣库存问题比想象中复杂得多。我这个月完整做了一个这样的系统从需求梳理到订单闭环已经跑通。这篇文章就围绕JSPServletMySQL这套组合讲讲怎么把一个公路车销售网站做得靠谱、能演示、能应付答辩也能作为小型电商项目的参考模板。公路车不像普通日用品用户买之前会反复对比车架尺寸、变速套件、刹车类型、整车重量和适用身高。因此数据库和页面展示都必须考虑这些专业属性。适合正在做Java Web项目、准备课程设计或毕设以及想快速搭建一个真实业务场景的朋友。文章会从需求拆解、技术选型、数据库设计到代码落地、排坑过程完整过一遍尽量用真实项目中的取舍来还原一个JSP项目的完整生命周期。1. 需求拆解公路车销售系统到底要管什么1.1 公路车商品具有哪些特殊属性公路车本质上是“参数驱动型”商品。一辆整车的SKU可能按尺码48、50、52、54cm、涂装黑、白、渐变色、套件等级入门、进阶、电变来区分。如果像卖普通T恤一样只维护一个“商品名价格”字段用户选尺码时就会完全失控。所以设计product表时除了基础字段还要预留规格说明字段和库存维度。我做系统时给商品表拆出了两部分一是公共字段比如名称、主图、价格、描述二是扩展字段包括车架材质、前叉类型、变速品牌、轮组尺寸、刹车类型、整车重量、适合身高范围、路况定位等。这些扩展字段不一定要单独建表简单项目可以直接用若干数据库字段但更规范的做法是设计一张“商品参数表”用key-value方式存。考虑到JSP项目通常数据量不大我最后选择了直接在商品表里加参数列代码看起来直观管理后台也方便编辑。公路车销售还有一个特点配件和整车一起卖。用户可能买整车的同时再加一副锁鞋、一个码表。所以系统里需要区分“整车”和“配件”两类商品甚至再按“公路车类型”细分二级分类。分类层级不能太深否则维护成本高对于演示项目两级分类足够。1.2 用户角色和核心功能边界这个系统主要面向两类角色普通用户和管理员。普通用户端的核心链路是注册登录、浏览商品、查看详情、加购物车、下订单、支付模拟支付、查看订单。管理员端则是商品上下架、分类管理、订单状态修改、用户列表管理、库存调整。要注意的是很多初学朋友会把“后台管理”做成一个独立项目结果两套代码要维护两遍登录逻辑。我的做法是同一个Web项目里通过URL前缀区分管理端比如/admin/*的请求单独走一套Filter和管理员权限校验。JSP渲染可以用同一个项目只是后台页面单独放在/admin目录下这样部署起来就一个war包很省事。订单状态是整个系统的核心。普通用户操作“取消订单”“确认收货”管理员操作“发货”“退款”这些动作在需求阶段就要明确状态范围。我最后把订单状态定义为待支付、已支付/待发货、已发货、已完成、已取消、退款中、已退款。不需要做得太复杂但状态之间不能乱跳比如已经发货就不能随意取消只能走退款流程。1.3 为什么还选JSP这套老技术栈有人问过都什么年代了为什么还选JSP答案是对于课程设计、毕业设计和小型业务原型JSPServletMySQL依然是一个性价比极高的组合。它不需要重型框架不需要微服务一个Tomcat跑起来就能演示。你在学校或小团队里学习Java Web第一套能跑通的电商系统大概率也是这套组合。选JSP而不是前后端分离还有一个关键原因方便做服务端渲染页面可以直接用EL表达式和JSTL从request域里取数据省掉了写接口、调接口、处理跨域的一系列流程。做完这套系统之后再去理解Spring MVC或Spring Boot里的DispatcherServlet、视图解析器其实是一脉相通的。与其说JSP过时了不如说它是理解Java Web核心机制的必经路径。2. 系统架构与关键技术选型2.1 经典三层架构的优势项目我采用的是最经典的Web层、Service业务层、DAO持久层三层架构。Web层由JSP和Servlet组成Servlet负责接收参数、调用Service、把结果放进request或session然后转发到JSP页面。Service层负责业务逻辑比如下单时先校验库存、再扣库存、创建订单、清空购物车这几个步骤必须放到同一个事务里。DAO层只做最简单的SQL执行和结果集映射。这套分层逻辑看似简单却踩过不少坑。第一版为了图快在Servlet里直接操作数据库写了个“下单”方法几百行代码结果后来要加一个“后台手动改价”的功能几乎把Servlet重写了一遍。改成Service层控制后Servlet只需要接收参数业务逻辑全部收口到Service复用性和调试体验都好了很多。对于一个网上销售系统如果业务逻辑散落在JSP脚本和Servlet中后期维护是灾难。三层架构最大的价值不是“听起来专业”而是让每一次状态变更都知道要去哪里改代码。这对单人开发项目同样重要因为一周后重新打开代码的你自己绝对不想看到一个“面条代码”文件。2.2 JDBC连接池和事务控制数据库这块我选了MySQL 5.7/8.0JDBC驱动使用com.mysql.cj.jdbc.Driver。项目里没有引入MyBatis或Hibernate因为一个老练的开发者用原生的JDBC就能把逻辑讲清楚。但原生JDBC每次创建Connection非常费时间所以必须要用连接池。我用的是Druid连接池因为它自带监控写SQL慢还能看到统计数据。在配置文件druid.properties里我重点关注这几项initialSize设为5maxActive设为20maxWait设为6000。这组参数对小型项目足够并没有刻意追求很大的池子。连接池不是越多越好maxActive设大了反而会拖累数据库。事务控制在Service层完成核心思路是connection.setAutoCommit(false)然后多条SQL共用同一个Connection所有成功再commit任何一个失败都rollback。这里有个非常容易犯的错误在DAO层直接获得连接又在Service层另开一个事务结果根本就不是同一个ConnectionSQL虽然没有报错但事务没有真正生效。我的做法是在Service中从连接池拿连接通过方法传入DAO或者用ThreadLocal绑定连接确保一个业务方法全程使用同一个Connection。2.3 前端组织和异步交互方式页面我用了BootStrap作为基础CSS框架但公路车商品列表要展示大量图片和规格标签所以我在商品卡片上做了不少自定义样式。公共头部和尾部用jsp:include包含菜单栏根据用户登录状态切换显示“登录/注册”或者“用户名退出”。传统JSP项目最怕每个操作都整页刷新。我在三块地方用了AJAX局部刷新加入购物车、购物车数量更新、订单状态的异步查询。用的是原生的XMLHttpRequest封装没有引jQuery因为现在项目引入框架越少越容易让别人复现。AJAX请求返回JSONServlet里输出JSON用简单的手写工具类拼接虽然不如Jackson优雅但依赖更少几十行代码就搞定。需要说明的是JSP页面里尽量别写大段Java脚本。老项目里经常看到% for(...){ %tr...% } %这在小页面上问题不大但页面逻辑复杂后很容易乱。我用EL表达式JSTL的c:forEach来遍历集合代码可读性明显更好。如果你第一次做这种系统建议从第一页就养成“页面中不出现Java代码”的习惯。3. 数据库设计把订单和库存之间的关系理顺3.1 核心表结构与关系数据库设计是这个系统最值得仔细打磨的地方。我总共设计了以下核心表user用户表、category商品分类表、product商品表、cart购物车表、orders订单表、order_item订单明细表、comment评论表可选。如果要做轮播图再加一张banner表但这不是核心。用户表比较简单字段包括id、用户名、密码哈希值、昵称、手机号、邮箱、创建时间。商品分类表用父子结构允许二级分类。商品表除了常规字段外还需要包含category_id、status上架/下架、stock库存、sales销量、price价格、main_image、carousel_images、description。订单表存user_id、order_no唯一订单号、total_amount总金额、status、receiver_name、receiver_phone、receiver_address、create_time、pay_time。明细表存order_id、product_id、product_name、product_image、spec_desc、price当时快照单价、quantity、subtotal。为什么订单明细里要冗余product_name和price这是很多人不理解的地方。因为商品可能改名、改价甚至下架但用户已经下单的明细必须永远保持“当时看到的样子”。如果不做快照哪天后台改了价格历史订单金额显示就会错乱。这个细节我在答辩时专门作为亮点讲老师通常都会认可。3.2 几个容易踩坑的字段设计细节价格字段必须用DECIMAL(10, 2)不能用FLOAT或DOUBLE。二进制浮点数在比较和求和时会产生精度误差价格出现0.1元偏差对电商系统来说是不可接受的。库存字段用INT并且在业务层保证非负。不要只用一个stock字段建议加一个locked_stock已锁库存字段用于下单后支付前锁定库存但这个小系统我做的是简单版下单时直接扣减库存。如果以后要做支付超时释放库存就需要设计库存冻结机制不然会出现“下单不付款把库存占着”的问题。商品参数里适合身高的字段我用的是VARCHAR比如“160-175cm”因为它是区间值参与搜索过滤时靠条件模糊匹配。体重、骑手身高这类数据不需要精确到数值否则用户在界面上选择起来非常麻烦。但车架尺码48/50/52则用VARCHAR并作为SKU关键属性参与购物车规格合并。3.3 订单状态机的设计思路订单状态不能只用“数字枚举”就完事还要在代码里明确谁可以执行什么操作。我的做法是在Service层写一个OrderActionValidator每次状态改变时判断当前状态与目标状态是否允许。比如“待支付”的状态码是0。用户点击“取消订单”状态从0变为5已取消这个允许。“已支付/待发货”状态码是1管理员发货后变为2已发货这个允许。用户不能在状态1直接“取消订单”到状态5因为此时仓库可能已经打包正确的流程是用户申请退款状态变为6退款中。如果是“已发货”状态用户只能点击“确认收货”不能直接取消。表设计时我给orders.status加一个默认值为0并在创建订单时用流水号生成唯一order_no。订单号的生成方式我采用的是“时间戳 3位随机数 用户id后四位”这样可以快速定位用户也避免了简单自增主键容易被猜测的问题。虽然不一定必须用这种规则但要保证订单号绝对唯一。4. 核心功能模块实现与代码要点4.1 注册登录与会话安全注册登录是系统的门面不能在安全上拉胯。密码存储我用了SHA-256 随机盐的方式每个用户有一个独立的salt注册时生成存到用户表中。密码校验时用同一个salt对输入密码再做一次哈希然后和数据库比较。虽然更推荐BCrypt或PBKDF2但在纯JSP项目里SHA-256加盐写法简单、没有额外依赖用来演示已经足够。登录用Session保存用户对象同时把用户id放入session。在Web层写一个LoginFilter拦截除了首页、商品列表、商品详情、登录和注册以外所有用户请求如果session中没有用户就跳转到登录页。管理员路径/admin/*单独拦截必须session里有管理员标记。这里有一个容易被忽略的问题JSP页面里如果要输出用户名等用户可控内容必须做HTML转义防止XSS攻击。EL表达式在JSTL中默认会转义输出但如果你用% out.print(name); %这种方式就不会转义。我的原则是尽量使用EL标签输出绝不在页面中直接拼接用户内容。4.2 商品搜索、筛选与排序公路车用户搜索习惯比较明确输入“碳架”“碟刹”“电变”等关键词或者按价格区间筛选。我实现了一个简单的搜索页参数包括category_id、keyword、min_price、max_price、sort、page。SQL在DAO层根据参数动态拼接。推荐的做法是用MyBatis动态SQL当然更方便但原生JDBC也不能硬拼字符串。我在Service层判断每个参数是否有效再传给DAO。价格区间作为一个安全参数必须通过PreparedStatement的?占位符绑定千万不能直接拼进SQL。关键词先用LIKE ?做前缀匹配比如keyword %。如果想匹配商品描述里的中间词就只能用%keyword%但要接受它用不上索引。小数据量没关系所以我后来依然用了%keyword%并在关键词字段上添加了一个简单索引实测速度可以接受。排序字段我做了白名单控制只有price_asc、price_desc、sales_desc、new_desc四种其他排序值一律使用默认new_desc。避免用户在参数里传入奇怪内容把SQL搞错。每次排序查询都用分页分页使用LIMIT ?, ?第一页返回20条后台管理分页也是同样的方式。4.3 购物车与下单结算流程购物车存储我选择了登录用户使用数据库表未登录用户临时用Session。为什么不把购物车全部放Session因为用户换浏览器或者清缓存后购物车就没了数据库表方案对真实销售场景更可靠。而且购物车表里我冗余了商品名和价格列表加载时减少关联查询。加入购物车时如果用户未登录先跳转登录。同一个用户同一商品同一规格如果已经存在就把数量加1否则新建一条记录。检查库存数量是否足够不够就提示“库存不足”。购物车页面展示每条记录的商品小图和规格描述允许修改数量或删除。下单结算流程是整个系统的核心我把事务写在了Service层的createOrder方法中步骤如下根据userId查询购物车项列表并逐个查询商品当前库存。计算总金额创建orders主记录状态设为0待支付。遍历购物车项创建order_item明细同时更新商品库存UPDATE product SET stock stock - ?, sales sales ? WHERE id ? AND stock ?。这条SQL是关键它利用数据库条件更新保证库存不会超扣。删除购物车中已下单的记录。提交事务。关键代码长这样简化String sql UPDATE product SET stock stock - ?, sales sales ? WHERE id ? AND stock ?; PreparedStatement ps conn.prepareStatement(sql); ps.setInt(1, qty); ps.setInt(2, qty); ps.setInt(3, productId); ps.setInt(4, qty); int rows ps.executeUpdate(); if (rows 0) { conn.rollback(); throw new BusinessException(库存不足下单失败); }这段逻辑可以在高并发下有效防止“超卖”。原理是UPDATE语句会锁定满足id ?的行即使两个请求同时读到库存为1第二个请求的UPDATE也会因为条件stock 2不成立而返回0行然后回滚事务。4.4 后台商品管理与订单管理后台管理我放在同一个项目里没有做前后端分离。商品管理页面支持分页列表、新增商品、编辑商品、上下架。新增和编辑表单都是一个大表单提交到AdminProductServlet。在Servlet里把字符串参数转成对应的Integer/Double类型同时对必填字段做校验商品名不能为空、价格必须大于0、库存必须大于等于0。图片上传我用的是简单的文件上传处理使用Apache Commons FileUpload组件。图片保存到Web项目下的upload目录数据库只存相对路径页面用相对路径访问。这里要注意防止上传超大图片拖慢服务器所以限制了单个文件最大5MB并且允许图片类型只限jpg/png/webp。订单管理页面是管理员使用频率最高的功能。列表按订单状态筛选管理员可以执行“发货”“取消订单”等操作。发货时要求填写物流单号物流信息存到订单表新增的字段里。点击发货后订单状态从1变2同时记录发货时间。所有这些操作都会在service层记录日志方便排查问题。5. 常见问题排查与性能安全优化5.1 中文乱码问题怎么彻底解决JSP项目中文乱码基本都是编码不一致。我踩过几次坑后总结了一套组合拳JSP文件顶部加% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8%。所有Servlet在读取参数前调用request.setCharacterEncoding(UTF-8)如果用了Spring的CharacterEncodingFilter那就学一下这个过滤器的原理。HTMLmeta charsetUTF-8。JDBC连接URL加上useUnicodetruecharacterEncodingUTF-8。MySQL数据库表默认字符集建议用utf8mb4因为utf8在MySQL里存不了部分生僻字和emoji。这里又回到那个老生常谈的问题不是所有地方都是UTF-8就没有乱码还要检查Tomcat的URIEncoding如果通过GET请求传中文最好在server.xml的Connector里设置URIEncodingUTF-8。5.2 数据库连接池和驱动不匹配使用MySQL 8.0时驱动类要写成com.mysql.cj.jdbc.Driver不再是com.mysql.jdbc.Driver而且URL里需要增加时区参数例如serverTimezoneAsia/Shanghai。如果时区不设置启动时经常报The server time zone value错误。连接池配置时我最常犯的错误是直接把Druid的username和password写错导致启动后大量连接失败。排查方法是在本地先用命令行连接MySQL用同样的用户名密码试一遍确认能连上再检查配置。这个简单的动作能排除80%的连接故障。如果服务器上连接被拒还要检查MySQL的bind-address和防火墙端口是否放行。5.3 超卖和库存不一致怎么处理前面提到了用UPDATE product SET stock stock - ? WHERE id ? AND stock ?来扣库存这是最简单有效的办法。另一个常见问题是用户在下单和支付之间库存被释放但我这个系统选择的方案是下单即扣库存支付忽略模拟支付时无需再校验库存。如果要做超时未支付自动取消需要增加“回补库存”的逻辑定时任务把未支付订单取消将库存数量加回去。同样要注意的是在后台手动修改商品库存时不要直接用“更新为某个值”的SQL不然可能把正在下单的用户扣减覆盖掉。建议用“库存调整值”方式例如UPDATE product SET stock stock ? WHERE id ?这样可以和其他操作叠加不容易互相覆盖。5.4 SQL注入和XSS防护实操SQL注入防御只有一个核心原则所有用户输入拼接SQL之前一律使用参数化查询。这个项目里我没有任何一条拼接用户输入到SQL原句的情况。对于排序字段这类需要动态变化的使用白名单。对于多条件查询先拼好“静态SQL骨架”再通过PreparedStatement按顺序设置参数。XSS防护方面用户提交的任何内容比如评论、收货地址都要在服务端做输入校验和长度限制展示到页面时优先使用EL表达式自动转义。如果要在某个局部输出富文本那就需要白名单标签过滤但一个小型公路车销售系统完全不需要支持富文本直接纯文本显示最安全。管理员登录后需要修改密码我会在修改密码接口增加旧密码校验防止在公共电脑上被利用。5.5 页面响应慢的常见原因商品首页如果查询很慢大概率是数据量不大但SQL嵌套循环太多。典型问题是商品列表页没做分页或者每个商品都去数据库查一次分类名称N1查询。我的解决方案是在查询商品列表时一次性把分类关联查好减少数据库往返次数。图片是影响页面响应的大头。商品列表页的缩略图我会在后台生成一份200x200的小图列表页只加载小图详情页才加载原图。图片文件用日期目录分开存放避免一个目录下文件过多影响IO。静态资源全部放在Tomcat的静态资源路径下不用经过Servlet处理。后台订单列表如果数据超过几万条LIMIT 100000, 20这种深分页会明显变慢。可以对这类查询增加一个条件用上一页最大id作为游标而不是翻页offset。这个小优化在答辩时可以提到会让别人觉得你考虑到了真实业务场景。5.6 部署与跨环境配置为了让项目能在不同环境下跑起来我把数据库连接配置放在druid.properties中没有写死在Java类里。部署时只需要修改配置文件里的URL、用户名密码。同时在项目里提供一个init.sql脚本包含建库建表和初始管理员账号数据方便把项目迁移到新机器。Tomcat部署记得把项目打成war包直接放到webapps目录。如果项目路径名比较长可以改名为roadbike.war访问入口就是http://ip:8080/roadbike/。管理后台入口是http://ip:8080/roadbike/admin/。上线前把Tomcat默认的8080端口改成不常用的端口并关闭管理控制台这些安全细节在真实项目中很重要。6. 几点个人体会两个月的开发周期里让我收获最大的不是JSP语法而是把一套“网上销售系统”的底层逻辑理顺“库存、订单、支付、物流”这几个环节看似独立实际上每一个状态变化都牵扯到数据一致性。用JSPServlet做这类项目其实是在用最原始的方式强迫自己理解Web请求处理、数据库事务和状态管理。做了一遍之后再去接触Spring Boot、MyBatis你会觉得这些框架做的事情自己都知道只是它们封装好了而已。最后分享一个小技巧给商品列表页加入一个“最近浏览记录”功能用session或者Cookie数组存最近看过的5个商品id每次商品详情页加载时把那几个商品再查出来展示在底部。这个功能开发成本极低但演示效果非常加分而且能引导用户再次购买。公路车用户本来就喜欢反复比较配置这个功能比“猜你喜欢”的推荐算法更加实用。如果后续想扩展可以把购物车从数据库迁到Redis也可以把订单模块抽成独立服务但先把基础流程跑通才是最重要的起点。