做毕业设计这些年,我收到过不少让我印象深刻的选题。今天想聊的这一个,也是其中之一:基于JSP的县域农产品销售网站。标题指向的是一个典型的地方特色农产品产地,但项目本身的技术架构和业务逻辑,放在全国绝大多数县域电商场景里都是通用的。一个规模不大的销售网站,要把商品展示、购物车、订单流转、后台管理这些基础电商功能完整跑通,同时还要踩得住MySQL、JDBC、Servlet、JSP这一整套Java Web基本功,最后还能在答辩现场讲得清楚、演示得流畅——这件事的价值,远比“又一个JSP大作业”要大得多。
如果你正在纠结选题、已经开题但没想清楚功能边界,或者只是想把一个传统Web项目做得逻辑严谨一点,这篇文章就是按我的实操经验拆解给你看的:从需求怎么来的,到技术栈怎么选,到数据库怎么设计,再到关键代码怎么写、部署有哪些坑,一条线走完。
1. 选题逻辑与需求分析:一个县域农产品网站到底要解决什么
1.1 这个题目为什么值得做
农产品的销售,最麻烦的不是种不出来,而是卖不出去。像苹果、天麻、花椒这类特色农产品,在产地往往物美价廉,但一到外面的市场,价格翻倍还不一定买得到正宗货。中间的差价去了哪里?大部分被中间商拿走了。想做产地直供,就绕不开一个信息发布和交易撮合的平台。
放在毕业设计里,这个选题有一个特别好的地方:它有现实参照物。你不必凭空想象需求,去找一个真实的农户、合作社或者电商服务站,看看他们的痛点就知道了:产品信息没人维护、价格变动频繁、订单记录靠手写、客户想回购找不到入口。这些痛点翻译一下,就是系统的功能清单。
1.2 从业务场景倒推出来的需求清单
我把这个项目拆成前台和后台两条线来看。前台是给买农产品的人用的,后台是给站点运营人员用的。想清楚谁在用,就不会把功能做成“什么都有但什么都不好用”。
前台功能
- 注册与登录:游客可以浏览,但下单必须先登录。这一步涉及Session会话管理和用户密码的处理。
- 商品分类浏览与关键词搜索:按蔬菜、水果、干货等分类展示,支持按名称搜索,背后是SQL动态查询。
- 商品详情与库存展示:详情页要显示产地、规格、价格、剩余库存,背后是一张商品表和一张库存字段。
- 购物车管理:加入购物车、修改数量、删除条目、合计金额,这是整个项目里业务逻辑最集中的地方之一。
- 下单与订单查看:生成订单、模拟支付(一般做成“提交订单后直接标记为已付款”或简单的支付状态切换)、查看历史订单和订单详情。
- 公告与推荐位:一般放在首页,让运营人员可以推新品或活动。
后台功能
- 管理员登录。
- 商品管理:增删改查、上下架、改库存、改价格。
- 分类管理:维护商品分类树。
- 订单管理:查看订单列表、发货、关闭异常订单。
- 会员管理:查看注册用户列表,删除恶意账号。
- 公告管理:发布和下线公告。
初学者容易犯的一个错误是,把后台做成前台的一比一复制。实际上后台的核心是“管货”和“管单”,不需要那么多花哨展示,反而需要高效的操作入口。
1.3 功能边界怎么控制
这个项目我划定的边界是:不接真实支付,不做物流对接,不做复杂的促销系统。支付环节用“模拟支付”代替,即下订单后出现一个支付按钮,点击后把订单状态从“待支付”改成“已支付”。这既降低了部署和演示的门槛,又不影响订单流转逻辑的完整性,还能在答辩时说清楚“真实支付需要对接第三方支付接口,我在项目中用状态机模拟了这个过程”,完全站得住脚。
2. 技术栈取舍:JSP + Servlet + MySQL为什么是毕设稳妥解
2.1 三层架构的第一原则:谁该干什么
这个项目用的是最经典的Java Web三层结构:JSP负责展示,Servlet负责接收请求和控制跳转,JavaBean/DAO负责数据访问。用大白话说,用户看到的页面是JSP,页面上的按钮点下去之后,请求会交到Servlet手里,Servlet再去问DAO要数据,拿到数据选择跳转到哪个页面,再把结果交给新的JSP去展示。
举例:用户在首页点了一个商品的“加入购物车”。这个请求是/cart/add?productId=5&quantity=2,交给一个名为CartServlet的Java类。它先判断用户有没有登录,没有就跳转到登录页;有的话,就读商品信息、检查库存,然后把这个商品写进Session购物车,最后跳回购物车列表页。整个链路没有任何多余依赖,每一步都能讲清楚。
2.2 为什么不直接上Spring Boot
我知道很多同学会问:现在企业里都SSM、Spring Boot了,为什么毕业设计还用JSP?我的回答是:毕业设计的首要目标不是追求最新技术,而是让你能完整讲清楚一条Web请求从浏览器到数据库再回到浏览器的路。
Spring Boot确实开发效率高,但它把很多东西都“自动配置”掉了。你用了@RestController,前端请求到方法之间的过程被框架藏住了;你用了MyBatis,SQL到结果集的映射被框架吃掉了。大多数学生做Spring Boot项目,最后答辩被问“这个请求怎么从浏览器到数据库的”,只能回答“框架帮我做的”。但JSP + Servlet这套组合,每个环节都得你亲手写,整个数据流都是你自己控制的,这恰恰是评委最爱问、也最能体现水平的地方。
当然,这并不是说Spring Boot不能用。如果你对Servlet原理已经很熟,想用更工程化的方案写,完全可以在项目里写“基于三层架构思想,参照Servlet规范实现控制层”,这思路同样成立。但作为稳的选择,JSP + Servlet + JDBC写出来的代码即使放到今天,也是一套非常完整的Java Web基础教学实现。
2.3 环境版本搭配建议
我建议的默认环境组合:
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| JDK | 1.8 | 稳定,兼容所有主流Tomcat和IDE工具 |
| Tomcat | 8.5或9.0 | 对应Servlet 3.1/4.0规范,够用 |
| MySQL | 5.7或8.0 | 8.0需要改驱动类名,细节见后面部署章节 |
| Java IDE | 任意主流Java IDE | 建议用导出WAR包的方式运行 |
| 数据库工具 | 命令行或图形化工具均可 | 导SQL脚本方便评委复现 |
这个组合最大的好处是资料多、坑少。网上随便搜一个Servlet上传下载、JSP分页的例子,几乎都能直接跑起来,不用折腾版本兼容。
3. 系统功能拆解:前台购物体验与后台库存订单管理
3.1 前台主流程的完整路径
一个访客从进站到买完东西,经历的业务路径是这样的:
- 访问首页,看到公告栏、分类导航和推荐商品列表。
- 点击分类“苹果”,进入列表页,上面有分页,左侧是分类树。
- 点击某个商品,看到详情,包括图片、价格、规格、库存。
- 点击“加入购物车”,系统判断是否已登录。未登录则去登录页;已登录则把商品放入购物车Session。
- 点击“去结算”,进入订单确认页,核对商品数量和金额。
- 点击“提交订单”,系统执行一个事务:扣库存、写订单表、写订单明细表,然后跳转到“支付页面”。
- 点击“立即支付”,订单状态从待支付变为已支付。
- 进入“我的订单”,查看订单列表,点开能看到每一件商品的明细。
这个流程里有一个关键点:库存扣减的时间点。国内电商的习惯是“下单减库存”或“支付减库存”,两种都有。这个项目如果下单就减库存,那么“未支付订单”会占用库存,过期订单需要定时释放。为了简化,我在设计里让“提交订单”时先检查库存并锁定,支付成功后正式扣减;如果长时间不支付,后台可以手动关闭订单,此时库存回补。这里涉及一个事务设计,第四、五部分会详细讲。
3.2 后台核心管什么
后台最重要的是订单管理,而不是商品管理。商品管理本质是CRUD,最难的无非是图片上传,但订单管理牵扯到状态变化、库存联动、用户沟通,逻辑复杂性高得多。
后台订单列表需要支持按状态筛选:待支付、已支付、已发货、已完成、已取消。运营人员看到一个新订单,点进详情能看到用户信息、收货地址、商品清单、支付状态。“发货”操作在系统里改的不只是订单状态,还应该把每个订单明细里的商品对应的库存做一次最终的确认扣减。这样设计,就算某个订单在下单和支付之间库存有变化,最终发货时也会以实际扣减为准。
商品管理还有一个容易忽略的点:下架与库存分离。一个商品可以“上架/下架”,下架后前台搜索不到,但后台仍然能操作库存。这两个字段别混在一起。很多学生用status字段同时表示上下架和删除,结果下架的商品在后台列表里也消失了,运营想重新上架还得靠SQL去改,非常狼狈。
3.3 用户角色与权限控制
系统一共两类角色:普通会员和管理员。权限控制用最简单的过滤器实现——写一个LoginFilter,检查Session里有没有登录用户,如果没有就重定向到登录页。再把需要登录的路径归纳为/cart/*、/order/*、/user/*,后台管理路径/admin/*单独再检查管理员权限。
这里有一个细节值得写进项目文档:不要在JSP页面里直接判断用户角色,尽量放到Servlet和Filter里面做。把权限控制放在JSP里,会出现“直接输入URL绕过登录页面”的情况,这在答辩现场被评委演示出来会非常尴尬。
4. 数据库设计:用订单状态把商品、会员、库存串成闭环
4.1 核心表结构一览
我把数据库设计放到了项目相当重要的位置。关系型数据库的设计直接决定DAO层代码好不好写。下面是这个项目的基本表清单:
| 表名 | 中文含义 | 核心字段 |
|---|---|---|
member | 会员表 | id, username, password, phone, address, create_time |
admin | 管理员表 | id, username, password, create_time |
category | 商品分类表 | id, name, parent_id, sort_order |
product | 商品表 | id, category_id, name, subtitle, price, original_price, stock, unit, image, status, sales_count |
cart_item | 购物车表(可选) | id, member_id, product_id, quantity, add_time |
orders | 订单表 | id, order_no, member_id, total_amount, status, receiver_name, receiver_phone, receiver_address, create_time, pay_time, ship_time |
order_item | 订单明细表 | id, order_id, product_id, product_name, product_image, price, quantity, subtotal |
notice | 公告表 | id, title, content, create_time, status |
这张表里,我特别解释两个设计决策。
4.2 为什么订单明细要冗余商品名称和价格
order_item里除了product_id外,还保存了product_name、product_image、price这类的快照信息。很多初学者不理解:明明可以联表查商品表,为什么要冗余?
原因很简单:商品信息是会被修改的。运营商改了商品名称、价格,或者把商品删了,如果订单明细只存一个product_id,历史订单里的金额、商品名就全对不上了。这在电商系统里是不可接受的。所以订单明细必须保存下单那一个瞬间的商品快照。多存几个字段,换来的是一条永远可追溯的订单记录。
类似的设计还体现在订单表本身:receiver_name、receiver_phone、receiver_address一次性从会员表里拷贝过来,而不是只存member_id。因为用户可能下单后改了收货地址,订单上的地址必须保持下单时的样子。
4.3 订单状态字段怎么设计
orders.status是系统的核心状态机。我用一个TINYINT类型字段,取值对应:
| 值 | 含义 | 说明 |
|---|---|---|
| 0 | 待支付 | 已下单未支付 |
| 1 | 已支付 | 支付完成,等待发货 |
| 2 | 已发货 | 运营已发货 |
| 3 | 已完成 | 用户确认收货或系统自动完成 |
| 4 | 已取消 | 用户取消或后台关闭 |
状态转换规则要在代码层严格限制:只有“待支付”能变成“已支付”或“已取消”;“已支付”只能变成“已发货”或“已取消”;“已发货”只能变成“已完成”。这些转换判断不要散落在各个JSP页面,而是集中在订单Service层里,用一个统一的changeOrderStatus(orderId, fromStatus, toStatus)方法处理。这在答辩时是一个很好的亮点:你已经有了“状态机”的意识。
4.4 库存与销量字段
product表里,stock表示当前库存,sales_count表示累计销量。每次支付成功时,两个字段要一起更新。注意:不要在页面上直接显示stock的原值,很多电商会做“库存余量模糊化”,比如显示“仅剩5件”。这个项目不需要那么复杂,但至少要在后端校验库存,不允许出现负数。避免负库存的做法,我放在后面核心代码里讲。
5. 核心代码实现:连接池、防注入、购物车与下单事务
5.1 数据库连接池与基础DAO
这个项目一开始就引入了连接池的概念。我用的是经典的开源连接池配置方式,在src下放一个db.properties:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/farm_mall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456然后在DBUtil里通过连接池的DataSource获取连接。这里不直接写DriverManager.getConnection,是为了让项目在高并发模拟下不至于频繁创建和销毁数据库连接,也让DAO层的代码更整洁:
public class DBUtil { private static DataSource dataSource; static { try { Properties props = new Properties(); props.load(DBUtil.class.getClassLoader().getResourceAsStream("db.properties")); dataSource = BasicDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }关于密码存储,我也多说一句:member.password和admin.password不要明文存。可以先用MD5做一次散列,或者更规范地用带盐的散列。毕业设计用MD5加固定盐足够演示了,但要主动说明“真实项目建议用bcrypt类算法”,这同样是答辩加分项。
5.2 PreparedStatement防注入的一行之差
在有搜索功能的列表页,最容易出现SQL注入。我见过很多项目直接在Servlet里拼SQL:
String sql = "select * from product where name like '%" + keyword + "%'";这个写法可以被一句话注入打穿。比如在搜索框输入%' or '1'='1,就能把全部商品查出来;如果再配合union,后果更严重。正确的做法是用PreparedStatement:
PreparedStatement ps = conn.prepareStatement( "select * from product where name like ?" ); ps.setString(1, "%" + keyword + "%");这个改动小到不能再小,但体现的安全意识差异非常大。我在项目里统一用PreparedStatement,不允许出现任何拼SQL的写法,还在代码注释里标了原因。这也是一个可以放在论文“系统安全设计”章节的内容。
5.3 购物车到底放Session还是放数据库
这个问题我纠结过,最后的选择是:购物车数据放Session,表里也建一张cart_item用于可选持久化。
Session购物车的核心逻辑是这样的:购物车本身是一个Map<Integer, CartItem>,key是商品ID,value里包含商品信息和购买数量。每次加购时,先从Session取出购物车对象,没有就新建一个:
@SuppressWarnings("unchecked") Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<Integer, CartItem>(); session.setAttribute("cart", cart); } CartItem item = cart.get(productId); if (item == null) { item = new CartItem(); item.setProduct(productDao.findById(productId)); item.setQuantity(1); cart.put(productId, item); } else { item.setQuantity(item.getQuantity() + 1); }购物车放Session的好处是:不登录也能加购、代码简单、服务器压力小。缺点是用户换设备就看不到购物车了。所以我在论文里写的是“本系统采用Session购物车方案,符合轻量级电商演示场景”,但在后台预留了cart_item表的DAO接口,把这个取舍说圆。
5.4 下单事务:扣库存与写订单必须同生共死
整个项目最核心的代码是“提交订单”这个事务。如果没有事务,会出现“订单写成功但库存没扣”或“库存扣了但订单没生成”的情况。这里我直接放核心代码骨架:
Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 1. 锁定商品行,防止并发超卖 String lockSql = "select stock from product where id = ? for update"; PreparedStatement lockPs = conn.prepareStatement(lockSql); lockPs.setInt(1, productId); ResultSet rs = lockPs.executeQuery(); int stock = 0; if (rs.next()) { stock = rs.getInt("stock"); } if (stock < quantity) { throw new RuntimeException("库存不足"); } // 2. 扣库存 String updateStockSql = "update product set stock = stock - ? where id = ?"; PreparedStatement updatePs = conn.prepareStatement(updateStockSql); updatePs.setInt(1, quantity); updatePs.setInt(2, productId); updatePs.executeUpdate(); // 3. 插入订单表 String insertOrderSql = "insert into orders(order_no, member_id, total_amount, status, ...) values (?,?,?,0,...)"; PreparedStatement orderPs = conn.prepareStatement(insertOrderSql, Statement.RETURN_GENERATED_KEYS); orderPs.setString(1, generateOrderNo()); orderPs.setInt(2, memberId); orderPs.setBigDecimal(3, totalAmount); orderPs.executeUpdate(); ResultSet keys = orderPs.getGeneratedKeys(); int orderId = 0; if (keys.next()) { orderId = keys.getInt(1); } // 4. 插入订单明细(循环每个购物车条目) for (CartItem item : cart.values()) { PreparedStatement itemPs = conn.prepareStatement( "insert into order_item(order_id, product_id, product_name, product_image, price, quantity, subtotal) values (?,?,?,?,?,?,?)" ); itemPs.setInt(1, orderId); itemPs.setInt(2, item.getProduct().getId()); itemPs.setString(3, item.getProduct().getName()); itemPs.setString(4, item.getProduct().getImage()); itemPs.setBigDecimal(5, item.getProduct().getPrice()); itemPs.setInt(6, item.getQuantity()); itemPs.setBigDecimal(7, item.getProduct().getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); itemPs.executeUpdate(); } conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); }代码里有三个细节。
第一,select ... for update是行级锁。两个用户同时买同一个商品时,第二个请求会被挡住,等第一个提交后再读取最新库存,从根本上避免超卖。
第二,订单号不要用自增ID当单号给用户看。我生成了一个基于时间戳加随机数的字符串,类似20250520163012123456,方便展示也方便后续对接物流。
第三,BigDecimal是金额计算的标准类型,千万别用double。比如0.1 * 3在浮点数里会得到0.30000000000000004,用户一看金额不对,体验很糟。
5.5 商品图片上传与回显
图片上传我用的是Servlet 3.0的Part接口,不用额外引第三方上传组件。核心逻辑是:接收文件,按日期生成子目录,写到服务器磁盘,再把相对路径存数据库:
Part part = request.getPart("image"); String fileName = extractFileName(part); String dir = getServletContext().getRealPath("/upload"); String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date()); File realDir = new File(dir, datePath); if (!realDir.exists()) { realDir.mkdirs(); } String storedName = UUID.randomUUID().toString().replace("-", "") + "_" + fileName; part.write(realDir.getAbsolutePath() + File.separator + storedName); // 存数据库的相对路径 /upload/20250519/xxx.jpg这里要注意,getRealPath指向的是Tomcat部署目录下的upload文件夹。如果你重新发布WAR包,上传的图片可能会丢。解决方法是把上传目录配置成一个外部绝对路径,然后在Tomcat里配置虚拟目录映射。不过毕设演示一般没有这个问题,知道有这个坑就行。
6. 本地部署与验收避坑:从Tomcat到浏览器中间的坑
6.1 版本组合里最容易被坑的两个点
版本坑往往藏在驱动和数据库上。如果你用的是MySQL 8.0,驱动类名要从com.mysql.jdbc.Driver改成com.mysql.cj.jdbc.Driver,连接URL里还必须带上serverTimezone=Asia/Shanghai,否则启动Tomcat时会报“The server time zone value”的异常。这个报错非常常见,不是你的代码错了,纯粹是驱动和数据库时区的兼容问题。
JDK版本同理。如果你用JDK 17配Tomcat 8.5,会报“UnsupportedClassVersionError”。JDK 8配Tomcat 9是当前最稳的组合,没有之一。
6.2 中文乱码:三个地方要统一
项目里中文乱码基本逃不出这三个位置:JSP页面、Servlet接收参数、数据库存储。一次都配好,就不会反复出问题。
JSP文件头部统一写:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>Servlet里接收请求参数之前,统一设置编码。最规范的做法是写一个EncodingFilter:
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setContentType("text/html; charset=UTF-8"); chain.doFilter(request, response); }数据库连接URL带上characterEncoding=utf8,建库时用utf8mb4。这样三个地方一致,中文乱码基本清零。
6.3 端口占用与发布方式
我遇到过不少同学本地跑得好好的,一演示就报“Port 8080 required by Tomcat v9.0 Server is already in use”。解决办法很简单:打开命令行,输入netstat -ano | findstr 8080,找到占用端口的进程ID,然后taskkill /PID <进程号> /F。如果杀了进程还是不行,就换一个端口,比如8088。Tomcat的端口配置在conf/server.xml里,改Connector port即可。
演示时我建议直接用Tomcat发布WAR包,而不是在IDE里点Run。把项目导出成WAR,放到Tomcat的webapps目录下,启动Tomcat后浏览器访问http://localhost:8080/项目名/。这种方式最接近真实部署场景,而且答辩时可以很从容地说“我把项目打包部署到了本地Tomcat”。
6.4 验收前必须模拟一遍完整用户路径
在真正给评委演示前,建议自己完整走一遍以下路径:
- 注册一个新账号。
- 用这个账号登录。
- 搜索商品,点分类,翻页。
- 把至少两件商品加入购物车。
- 修改购物车数量,删除一条,重新合计金额。
- 提交订单,支付订单。
- 到“我的订单”里查看该订单,确认状态变化。
- 后台登录,在订单管理里找到这个订单,点发货。
- 回到前台,看到订单状态变成已发货。
这条路走通了,系统主流程就算稳了。我特别提醒:一定要用新注册的账号走,不要用提前写死在数据库里的账号。因为注册、登录、会话这一整条链路是评委最容易现场验证的部分,你提前埋好的测试账号反而容易露怯。
7. 答辩演示中的几个加分细节
最后一个部分,我想聊聊技术之外的事情——答辩和演示。代码写得再好,表达不清晰也很亏。
首先,准备一张“请求流程图”的描述稿。不一定用什么工具画,但要能口头讲清楚:浏览器发请求到Servlet,Servlet调DAO,DAO访问数据库,数据返回后如何填充到JSP。这就是前面说的三层架构,把这条链路讲顺,评委就知道你确实是自己做的,不是网上抄的。
其次,把项目里你觉得最“硬核”的三个点准备好:PreparedStatement防注入、select for update防超卖、订单状态机控制。这三点任选一个深聊,都能聊出实质内容。如果评委问“你项目里最难的地方是什么”,不要说是“登录注册”,直接说“订单提交这个功能涉及库存和订单的一致性,我用数据库事务解决了”,整个答辩的层次立刻就不一样。
最后,演示的时候刻意制造一次“被追问”的机会。比如当评委问“库存会不会被扣成负数?”,你就可以在后台把库存改成1,然后到前台买2件,现场演示系统正确地提示“库存不足”。这种真实的边界测试,比背十页PPT都管用。
我也得坦白一句:这个项目做完,我的感受是它不只是一个“交差”的系统。从选题到上线,你其实走了一遍完整的软件工程流程:业务调研、需求分析、功能设计、数据库建模、编码实现、测试部署。哪怕用的技术不新,思路却是每个做Web开发的人都必须反复训练的。如果你正在为它熬夜改Bug,不如停下来先把订单事务和权限过滤这两块好好理一遍——做完它们,这个项目的技术含金量就真的稳住了。