简介:基于JSP+Servlet+JavaBean的超市进销存管理系统源码压缩包,面向需要完成毕业设计或课程设计的计算机相关专业学生,以超市进货、销售、库存和供应商信息管理为主线,融合JSP、Servlet、MySQL等常用技术,难度适中,适合用来理解传统JavaWeb分层开发流程。压缩包共134个文件,体积6.94MB,包含.java源文件、.class编译文件、.jsp页面、.css/.js样式脚本、.jar依赖库,以及jpg/png/gif等界面素材和XML等配置文件,既有可直接运行的完整工程,也便于对照源码学习前后端交互与数据库操作。目前已有68人学习/浏览,作为课程设计或备查资料具备一定参考价值。项目内含商品、用户、管理员等实体类、控制器、数据库操作类和导入导出工具类,结构清晰;下载后按配套文档配置MySQL与Tomcat环境即可运行,便于快速跑通流程并在此基础上扩展功能。从控制器、实体类到数据操作与工具类,整体分层思路较清楚,有利于二次开发和答辩讲解。
1. JSP+Servlet+JavaBean的老技术栈,做个超市进销存还要不要选它
拿到这份“基于JSP+Servlet+JavaBean的超市进销存管理系统.zip”,你大概率是正在做课程设计或者毕业设计的计算机专业学生,也可能是想快速搭一套内部管理系统的小团队。这个压缩包对应的是一套很典型的Java Web工程:商品档案、进货单、销售单、库存预警、用户登录和权限拦截,所有功能都靠JSP、Servlet、JavaBean这三件套手写出来,不依赖Spring这类重型框架。很多人第一反应是“这技术太老了,还有必要碰吗”,但恰恰是这套老技术栈,能让你把HTTP请求怎么进Tomcat、Session怎么保持登录态、JDBC怎么连数据库这些底层原理一次看清。后面学Spring MVC时,你会发现很多设计都是在给这套手写流程做封装,所以先把这个项目跑通、读透,账一点都不亏。
2. 搭建超市进销存的工程骨架:三层分包、核心表与JDBC连接封装
2.1 从MVC看JSP、Servlet、JavaBean各自分到的活
“JSP+Servlet+JavaBean”不是三个组件随便堆在一起,而是MVC模式在Java Web里的经典落地。JSP承担View,负责把数据渲染成页面;Servlet承担Controller,接收请求、调用业务逻辑、决定跳转;JavaBean承担Model,封装商品、订单这类业务数据,同时配合DAO类完成数据库读写。很多人初次写这个项目会犯一个通病:在JSP里直接写JDBC代码,页面里塞满<% %>脚本段,一个月后再看自己都看不懂。正确的做法是让JSP页面只出现HTML和JSTL标签,Java代码全部下沉到Servlet和JavaBean里。
这个项目的典型分包结构是:
| 包名 | 职责 |
|---|---|
| entity | 实体类,对应数据库表的字段,例如Goods、Supplier、Purchase |
| dao | 数据访问层,用JDBC操作数据库,例如GoodsDAO、PurchaseDAO |
| servlet | 控制层,接收请求、调用DAO、转发或重定向 |
| util | JDBC连接工具、字符串工具类 |
| filter | 登录拦截、编码处理过滤器 |
JavaBean在JSP时代还有一个专门的用法:通过<jsp:useBean>标签在页面里创建和访问对象。但实际做进销存系统时,我更推荐把它当成普通POJO使用,配合Servlet和DAO完成数据流转,这样结构更清晰,也不容易踩作用域坑。JSP里要展示商品列表,Servlet从DAO拿到List<Goods>,塞进request域后转发给JSP,JSP只用${goods.goodsName}就能输出数据。
2.2 建表设计:商品、进货、销售、库存四类数据怎么落库
超市进销存的核心数据,可以拆成四张主表和两张明细表。商品表goods用来存商品档案,进货表purchase和销售表sale分别记进货单和销售单,业务上“一张单对应多个商品”,所以还需要purchase_item和sale_item两张明细表。库存不需要单独建表,它直接作为goods表里的stock字段存在,通过进货和销售时更新商品记录来维护。
建库建表时有一个很关键的细节:数据库字符集一定要用utf8mb4,否则后面存商品备注里的中文表情符号,或者部分生僻字,会直接乱码或者报“Incorrect string value”。建库语句我一般这样写:
CREATE DATABASE shop_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE TABLE goods ( id INT PRIMARY KEY AUTO_INCREMENT, goods_code VARCHAR(32) NOT NULL, goods_name VARCHAR(64) NOT NULL, spec VARCHAR(32) DEFAULT '', price DECIMAL(10,2) NOT NULL, purchase_price DECIMAL(10,2) DEFAULT 0.00, stock INT DEFAULT 0, stock_low INT DEFAULT 5, category_id INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这张商品表里,stock_low是库存预警阈值,当stock <= stock_low时,列表页就要给这一行标红提示补货。金额字段用DECIMAL(10,2)而不是double,是因为浮点数在计算金额时会有精度丢失,0.1加0.2不等于0.3这种问题在后面对账时会非常麻烦。购买记录表里还要记录单价和金额,因为商品价格可能变动,进货单要保留当时的价格快照。商品编码建议用带业务含义的编号,例如前两位是分类,后四位是流水号,这样在门店盘点时扫码找货更方便。
2.3 把JDBC连接封装进DBUtil,参数写在一个文件里
整个系统所有DAO都要拿数据库连接,如果每个类都重复写一遍DriverManager.getConnection,后期换数据库或者改密码就得满工程替换。我一般会抽一个DBUtil类,把URL、用户名、密码集中放在静态常量里,再用静态代码块加载驱动类。
package cn.shop.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/shop_db" + "?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/Shanghai" + "&allowPublicKeyRetrieval=true"; private static final String USERNAME = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USERNAME, PASSWORD); } }这段代码里几个参数值得说明一下。characterEncoding=utf8保证从数据库读出来的中文不乱码;serverTimezone=Asia/Shanghai是MySQL 8.x的必填参数,不设会报时区错误;allowPublicKeyRetrieval=true是MySQL 8.x首次连接时用缓存SHA-256密码认证需要的开关;useSSL=false则避免一堆SSL警告日志。如果你用的是MySQL 5.x,驱动类名要换回com.mysql.jdbc.Driver,URL里时间戳参数也可以去掉。
注意看,我把Class.forName放在静态代码块里,而不是每次连接时都加载一次驱动。因为驱动类只需要注册一回,重复加载虽然不报错,但确实属于多余开销。这个DBUtil类要放到util包里,其他DAO通过DBUtil.getConnection()拿连接,用完在finally块里关闭,或者直接用Java 7的try-with-resources语法自动关闭。
2.4 web.xml和页面编码:跑起来之前先定好的三件事
工程要能跑,web.xml里有三个配置不能省:欢迎页、字符编码过滤器、Servlet映射。如果你用的是Servlet 3.0以上的Tomcat,Servlet可以用@WebServlet注解免去web.xml里的映射配置,但欢迎页和过滤器仍然建议写在web.xml里。欢迎页直接指向登录页,避免访问根路径时Tomcat弹一个目录列表出来。
<welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> <filter> <filter-name>encodingFilter</filter-name> <filter-class>cn.shop.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>这个编码过滤器会在所有请求进入Servlet之前把request.setCharacterEncoding("UTF-8")执行掉,解决POST请求的中文参数乱码。GET请求的中文参数在Tomcat 8.5以上默认就是UTF-8解码,不用额外处理;如果是老Tomcat,还需要在server.xml的Connector上配置URIEncoding="UTF-8"。
JSP文件头也要统一加上pageEncoding="UTF-8",否则文件里的中文注释和写死的提示文案在浏览器里会显示成乱码。这三件事在项目一开始就定好,后面调试的时候能少掉一半的“怪问题”。我们经常说的“同一套代码换台电脑就乱码”,八成就是这三个地方的编码不一致导致的。
3. 商品管理模块完整链路:DAO、Servlet、JSP三步走并带上分页
3.1 实体类和商品DAO:增删改查的每一步都不能省校验
商品管理是进销存系统里最基础也最能体现功底的模块。先从实体类开始,Goods类的字段要和goods表一一对应,这里我用BigDecimal存价格,避免浮点误差。
package cn.shop.entity; import java.math.BigDecimal; public class Goods { private Integer id; private String goodsCode; private String goodsName; private String spec; private BigDecimal price; private BigDecimal purchasePrice; private Integer stock; private Integer stockLow; private Integer categoryId; private String createTime; // 每个字段的getter/setter省略 }实体类里的字段命名和数据库列名保持一致,后面用ResultSet取值时可以直接按列名get。stockLow这个字段在列表页做预警高亮时要频繁读取,不要偷懒不写getter。
接着写GoodsDAO,这里主推用PreparedStatement而不是Statement,一方面防止SQL注入,一方面参数占位符让SQL语句可读性更好。以搜索加分页这个最常用的查询为例:
public List<Goods> search(String keyword, int pageNo, int pageSize) { List<Goods> list = new ArrayList<>(); String sql = "SELECT * FROM goods " + "WHERE goods_name LIKE ? OR goods_code LIKE ? " + "LIMIT ?,?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); ps.setString(2, "%" + keyword + "%"); ps.setInt(3, (pageNo - 1) * pageSize); ps.setInt(4, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Goods g = new Goods(); g.setId(rs.getInt("id")); g.setGoodsCode(rs.getString("goods_code")); g.setGoodsName(rs.getString("goods_name")); g.setStock(rs.getInt("stock")); g.setStockLow(rs.getInt("stock_low")); list.add(g); } } } catch (SQLException e) { throw new RuntimeException("查询商品列表失败", e); } return list; }这段代码用了try-with-resources语法,Connection、PreparedStatement、ResultSet都会在方法结束时自动关闭,不用再写冗长的finally块。LIMIT的偏移量是(pageNo - 1) * pageSize,这地方新手经常算错,页码从1开始,第一页要查的是第0条到第pageSize条,所以偏移量是0而不是pageSize。LIKE匹配时要自己拼%符号,注意这里拼接的是字符串,不是用户输入,安全上没问题。实际开发中查询条件往往还要加上category_id、库存区间,用StringBuilder动态拼SQL和参数列表就好。
新增和更新商品时,除了INSERT和UPDATE的SQL不同,逻辑上要注意两点:一是价格、库存这类数字字段要做非空校验,页面传来的空字符串直接Integer.parseInt会炸;二是更新操作最好先按id查一遍,确认商品存在再执行,否则影响行数为0时容易误判成更新失败。
3.2 商品Servlet:doGet查数据,doPost写数据,顺序别颠倒
Servlet是MVC里的控制层,它的职责是解析请求参数、调用DAO、决定页面跳转。很多初学者把增删改查全都塞进doPost里,这是不对的。我一般这样分工:doGet处理列表查询、进入编辑页、删除确认跳转;doPost处理表单提交的新增和更新。用@WebServlet("/goods")注解映射,省去web.xml里的servlet配置。
@WebServlet("/goods") public class GoodsServlet extends HttpServlet { private GoodsDAO goodsDAO = new GoodsDAO(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("edit".equals(action)) { int id = Integer.parseInt(req.getParameter("id")); req.setAttribute("goods", goodsDAO.findById(id)); req.getRequestDispatcher("/WEB-INF/pages/goods_edit.jsp").forward(req, resp); return; } // 默认走列表查询 int pageNo = req.getParameter("pageNo") == null ? 1 : Integer.parseInt(req.getParameter("pageNo")); int pageSize = 10; String keyword = req.getParameter("keyword"); if (keyword == null) { keyword = ""; } Page<Goods> page = goodsDAO.findPage(keyword, pageNo, pageSize); req.setAttribute("page", page); req.getRequestDispatcher("/WEB-INF/pages/goods_list.jsp").forward(req, resp); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String action = req.getParameter("action"); if ("save".equals(action)) { // 读取表单字段,组装Goods对象 // 新增或更新入库 } else if ("delete".equals(action)) { // 按id删除 } resp.sendRedirect(req.getContextPath() + "/goods"); } }这里有一个非常重要的设计:查询操作用forward转发,写操作结束后用sendRedirect重定向。原因是POST提交后如果直接转发到列表页,用户按F5刷新浏览器会再次提交表单,造成商品重复插入;重定向会先让浏览器重新GET一次/goods,刷新时只刷新列表,不会重放表单。这个模式叫PRG,理解之后以后用Spring MVC时你会经常见到它。
另外注意,转发路径前有/WEB-INF/pages/前缀,这是把JSP页面放在WEB-INF目录下保护起来,用户无法直接通过URL访问页面,只能通过Servlet跳转进入。登录模块做好之后,这个设计还能防止未登录用户直接打开页面源码。
3.3 用JSTL渲染列表页,分页和搜索条件一起保持住
JSP页面里不建议写大段Java脚本段,显示商品列表用JSTL的<c:forEach>标签就够:
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <thead> <tr> <th>商品编码</th><th>商品名称</th><th>库存</th> <th>预警线</th><th>售价</th><th>操作</th> </tr> </thead> <tbody> <c:forEach items="${page.list}" var="g"> <tr> <td>${g.goodsCode}</td> <td>${g.goodsName}</td> <td>${g.stock}</td> <td>${g.stockLow}</td> <td>${g.price}</td> <td> <a href="${pageContext.request.contextPath}/goods?action=edit&id=${g.id}">编辑</a> <a href="${pageContext.request.contextPath}/goods?action=delete&id=${g.id}" onclick="return confirm('确认删除该商品吗?')">删除</a> </td> </tr> </c:forEach> </tbody> </table>页面里所有路径都用${pageContext.request.contextPath}拼出来,这样项目部署到Tomcat的任意名字的webapp下都不怕路径错误。删除操作我在演示代码里用了GET链接,但在真实系统里删除是修改数据,应该改成POST表单提交,否则链接被搜索引擎爬虫或者预加载插件碰到就会误删商品。
列表页下方的分页条,用<c:forEach>循环输出页码,点击时将当前页码作为pageNo参数传给/goods。这里有一个隐蔽的坑:搜索时输入了“可乐”,点击第2页时如果分页链接只传了pageNo,keyword就丢了,第二页又会回到全量列表。所以分页链接必须把当前的keyword一起带上:
<c:forEach begin="1" end="${page.totalPage}" var="i"> <a href="${pageContext.request.contextPath}/goods?pageNo=${i}&keyword=${param.keyword}">${i}</a> </c:forEach>表格操作列如果只是三个普通文本链接会显得简陋,常见做法是引入Element UI图标库之类的前端资源来美化按钮。在JSP里用这类图标和普通网页没什么区别,真正要小心的是路径问题:静态资源要放在webapp/statics目录下,引用时同样用${pageContext.request.contextPath}/statics/css/index.css拼接,否则刷新页面图标就消失。图标字体在JSP里的坑多半是相对路径失效,把icon的class名挂在<i>标签上,注意font-face的url路径要用绝对路径。
3.4 商品列表的库存预警高亮,写DAO别写页面
超市进销存里库存预警是个高频需求。实现方式有两种:一种是在商品列表页遍历时判断stock <= stockLow,然后给表格行加一个CSS class;另一种是查询时就直接查出来。我更推荐在DAO里把这条判断留给页面,但不在JSP里写Java判断,而是引入一个status字段。
public Page<Goods> findPage(String keyword, int pageNo, int pageSize) { // 常规分页查询代码 // SQL里加一列: // CASE WHEN stock <= stock_low THEN 1 ELSE 0 END AS low_stock_flag }JSP里用<c:if>判断这个flag,为1时给<tr>加class="danger",CSS里定义红色背景半透明色即可。这样写的好处是,以后要改成按店铺维度预警、批量采购提醒,只需要改SQL和一个JSP条件,不用动页面结构。
商品管理模块整个链路跑通之后,你应该已经体会到“Servlet收参数、DAO管SQL、JSP管展示”三层协作的节奏了。这套节奏是后面所有业务模块的模板。
4. 进货、销售与库存联动:用事务和条件更新保住库存数据
4.1 进货单提交:主表、明细表、库存三个写操作必须绑在一起
超市进销存的业务核心,在于进货和销售都会改变库存,而这两类操作都不只是“改一个数字”那么简单。一次进货,需要同时做三件事:在进货主表purchase里插入一条单据记录、在进货明细表purchase_item里插入本次进货的商品明细、把每件商品的可卖库存累加回goods表。这三件事要么全部成功,要么全部失败,不能用代码里if判断代替,必须用数据库事务。
Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 插入进货主表,拿到自增主键 String insertPurchase = "INSERT INTO purchase(order_no, supplier_id, total_amount, operator, create_time) VALUES (?,?,?,?,NOW())"; PreparedStatement ps = conn.prepareStatement(insertPurchase, Statement.RETURN_GENERATED_KEYS); ps.setString(1, orderNo); ps.setInt(2, supplierId); ps.setBigDecimal(3, totalAmount); ps.setString(4, operator); ps.executeUpdate(); ResultSet rs = ps.getGeneratedKeys(); rs.next(); long purchaseId = rs.getLong(1); // 2. 逐条插入明细表并更新库存 for (PurchaseItem item : items) { PreparedStatement psItem = conn.prepareStatement( "INSERT INTO purchase_item(purchase_id, goods_id, quantity, price, amount) VALUES (?,?,?,?,?)"); psItem.setLong(1, purchaseId); psItem.setInt(2, item.getGoodsId()); psItem.setInt(3, item.getQuantity()); psItem.setBigDecimal(4, item.getPrice()); psItem.setBigDecimal(5, item.getAmount()); psItem.executeUpdate(); PreparedStatement psStock = conn.prepareStatement( "UPDATE goods SET stock = stock + ? WHERE id = ?"); psStock.setInt(1, item.getQuantity()); psStock.setInt(2, item.getGoodsId()); psStock.executeUpdate(); } conn.commit(); } catch (Exception e) { if (conn != null) { conn.rollback(); } throw new RuntimeException("进货单保存失败", e); } finally { if (conn != null) { conn.close(); } }这段代码里的关键点是setAutoCommit(false)和commit()/rollback()配套使用。默认情况下JDBC每次executeUpdate都会自动提交,一旦明细插入到一半数据库挂了,主表里有单、库存没加上,账目就对不上了。手动开启事务后,三个写操作共用一个Connection,任何一步抛异常都回滚到初始状态。Statement.RETURN_GENERATED_KEYS配合getGeneratedKeys()拿到数据库自动生成的主键,这一步在自增主键的MySQL里是唯一可靠的方式,不要想着自己生成一个purchase_id传进去,并发一高就可能撞键。
有同行会问,这事务不就是把代码包一层try-catch吗?实际坑在细节里:conn在事务开启后如果发生异常没有rollback就关闭连接,MySQL会按配置自动回滚或提交,这种不可控的状态最危险。所以catch块里必须显式rollback(),finally里再close。
4.2 销售出库防超卖:先查询再判断,不如一条条件更新
销售模块是反向操作,库存扣减逻辑和进货正好相反。新手写扣库存时通常会这样做:先SELECT stock FROM goods WHERE id=?,Java代码里判断stock >= quantity,再UPDATE goods SET stock=stock-? WHERE id=?。这在小门店单机部署时没问题,但只要被两台收银机同时访问,就会出现超卖:两个请求都读到库存还有10,都判断可以卖,都去扣减,最后库存变成负数。这就是并发下的竞态条件。
正确的办法是把判断和扣减合并成一条带条件的UPDATE,让数据库来保证原子性:
String sql = "UPDATE goods SET stock = stock - ? WHERE id = ? AND stock >= ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, quantity); ps.setInt(2, goodsId); ps.setInt(3, quantity); int rows = ps.executeUpdate(); if (rows == 0) { // 说明库存不足,抛异常回滚整笔销售单 throw new SQLException("商品ID=" + goodsId + " 库存不足"); }WHERE stock >= ?这个条件,确保了扣减操作只在库存足够时执行,数据库的行锁会串行化并发请求,后到的请求发现库存已经被扣光,影响行数为0,于是走回滚。这比Java代码里if (stock < quantity)的判断严谨得多,已经是乐观锁思想的雏形。销售模块同样要开事务:插入销售主表、插入销售明细、扣减库存三件事,和进货模块的代码结构对称。门店场景里还经常要做“退换货”,那就是把扣减改成增加,同时反向记一条退货流水,这个封装成reverseSale()方法即可。
4.3 进货与销售共用一张库存账:数据准确性靠对账
进销存系统跑到月底,老板最关心的问题一定是“账上的库存和实际货架上的数对得上吗”。商品库存的准确性,本质上取决于进货和销售两条业务线是否都严格走了“单据+明细+库存变动”三步。最常出问题的位置是“直接改库存”:新手图省事,在商品编辑页加了一个库存字段,经理手一抖把库存改了,没有留下任何业务单据,月底对账时就找不出这笔差异来源。所以做这个项目时,我强烈建议商品编辑页里的“库存”字段只展示不可编辑,库存变动只能通过进货单和销售单触发。
进货单里的order_no要设计成带规则的单号:进货用PO加日期再加流水号,销售用SO加日期加流水号,这样从数据库里按单号排序就能看出一天的业务量,打印小票时也不用额外拼接。明细表里保存price和amount快照,是为了将来商品涨价后,老订单的金领和库存仍然能按当时的进价来核对毛利,而不是随商品档案的价格漂移。
4.4 给业务代码留三个检查口:数量、金额、日期
进货销售这两个模块的代码跑通后,我建议在提交路径上留三个检查口。第一,数量字段必须大于0,前端表单做了校验,后台Servlet里也要再校验一次,因为总有人能绕过前端;第二,金额要和单价乘以数量核对,防止前端把总金额传过来,后台直接采信;第三,单据日期不要取客户端时间,要用数据库的NOW(),否则收银机系统时间不准会导致日结报表错位。这三个检查口看起来琐碎,但恰恰是这类系统的血泪经验所在。我在课程设计答辩时,老师最喜欢问的也是“你如何保证数量和金额的合法性”,把这三条讲清楚,加分效果很明显。
5. 常见问题排查:端口占用、ClassNotFound、乱码与登录失效的5个现场
5. 常见问题排查:500、404、ClassNotFound、乱码与登录失效的5个现场
5.1 404与路径问题:上下文路径忘了带
现象:项目启动成功后,浏览器访问http://localhost:8080/goods报404,Tomcat控制台没有任何报错。原因有两个高频点:一是Servlet映射路径写成了/goods,但项目部署名不是根路径,实际必须访问/shop/goods;二是JSP里链接写成了href="/goods",少了${pageContext.request.contextPath}前缀,浏览器把请求发到了Tomcat根路径下。解决方法是全局搜索所有href和src属性,统一替换成<c:url>或者${pageContext.request.contextPath}拼接。另外/WEB-INF/pages/下的JSP不能直接通过URL访问,这是保护机制,不是404配置错误。
5.2 中文乱码:页面、请求、数据库三层要同时对齐
现象:商品名称在列表页显示成“???”,或者表单提交后数据库里存的是乱码,更隐蔽的是同一套代码别人电脑上正常、自己电脑上乱码。原因在于JSP页面编码、Servlet请求编码、数据库字符集三个环节只要有一个不一致,中文就会在某一步被错误解码乱掉。解决办法按顺序检查:JSP文件头的pageEncoding="UTF-8"、Web.xml里的EncodingFilter、JDBC URL里的characterEncoding=utf8、数据库创建时的utf8mb4。Tomcat 8.5以上版本GET请求默认UTF-8,如果是老版本,还要在server.xml的Connector上补URIEncoding="UTF-8"。中文乱码解决起来不难,难在要养成“从页面到数据库一条链路排查”的习惯,不要只改一处就以为能好。
5.3 ClassNotFoundException:驱动类明明导入了还是找不到
现象:Tomcat启动后访问第一个查询商品的请求,控制台报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因通常是MySQL驱动jar包没有发布到WEB-INF/lib目录下。Eclipse或IntelliJ IDEA里,有时候你在Build Path中添加了jar,但它不会自动复制到部署目录里。另外注意驱动类名:MySQL Connector/J 8.x的驱动类改成com.mysql.cj.jdbc.Driver了,5.x是com.mysql.jdbc.Driver,类名写错也会报ClassNotFound。解决方法是把mysql-connector-java的jar手动放到WEB-INF/lib下,然后右键项目选择刷新并重启Tomcat,确保部署目录里真的存在这个文件。
5.4 8080端口被占用:上次没关干净还是别的服务在跑
现象:启动Tomcat时报Port 8080 required by Tomcat v9.0 Server at localhost is already in use,有时候还伴随SEVERE: Failed to initialize end point。原因多半是上一个Tomcat实例没有完全关闭,或者电脑上装了其他占8080端口的服务。解决方法是终端执行netstat -ano | findstr :8080,找到监听8080的PID,再用taskkill /PID 进程号 /F结束掉。如果8080被一些系统服务占用且不能杀,就修改conf/server.xml里的HTTP端口,注意8005和8009两个端口也要一起改成不冲突的值,改一个端口仍然可能因为其他两个端口被占用而启动失败。
5.5 登录后跳回首页又变成未登录状态
现象:输入用户名密码登录成功后跳转到主页面,但主页面的头部不显示用户信息,访问其他受保护页面又被弹回登录页。解决办法分三步排查:先看在LoginServlet里有没有执行request.getSession().setAttribute("currentUser", user);再看Filter有没有把登录请求本身拦截掉,导致Session刚建立就被重定向;最后检查浏览器F12里的Cookie,JSessionId有没有正常种下,Cookie的Path是否是/项目名。一个很隐蔽的坑是,用localhost登录和用127.0.0.1登录,浏览器认为是两个站点,Cookie不互通,这边登录了那边还是未登录,排查时统一访问地址再试。另外浏览器禁用Cookie时Session完全失效,前端登录页要提示用户开启。
注意:Filter拦截路径判断不要用
uri.startsWith("/login"),要加上contextPath再判断,否则项目部署名下的/shop/login.jsp和/shop/loginxxx都会被意外放行或拦截。
5.6 改了JSP页面不生效:看起来像是没保存,其实是Tomcat缓存
现象:改了JSP文件里的文字,刷新页面还是旧内容,重启Tomcat之后才正常。这种现象在开发期被我们戏称为“玄学”。原因在于Tomcat会把JSP编译成class缓存到work/Catalina目录下,IDE增量发布有时没有触发重新编译。解决分两步:第一,IDE里执行Project > Clean强制重新编译;第二,手动删除Tomcat安装目录下work/Catalina里对应的项目缓存文件夹,然后重启。之后养成习惯,改JSP后看一眼Tomcat控制台有没有重新编译的日志输出,没有的话就手动清理。
排查这类问题,我通常建议按“浏览器F12看Network → 看HTTP状态码 → 看Tomcat catalina日志 → 再定位到DAO的SQL”这个顺序走。前端能看到404、500就直接定位到路径和Servlet;前端正常但数据不对,才需要去翻SQL和数据库,不要一开始就怀疑代码逻辑,那样只会越查越乱。
6. 验证与后续演进:手工SQL验库存,再决定要不要上框架
整个系统跑通后,最重要的不是急着加功能,而是先验证数据链路是否正确。我最常用的验证方式是写一条对账SQL,把商品表里的库存和进货明细、销售明细的合计差做比对。
SELECT g.goods_name, g.stock AS current_stock, (IFNULL(SUM(pi.quantity), 0) - IFNULL(SUM(si.quantity), 0)) AS flow_stock FROM goods g LEFT JOIN purchase_item pi ON pi.goods_id = g.id LEFT JOIN sale_item si ON si.goods_id = g.id GROUP BY g.id, g.goods_name, g.stock HAVING current_stock != flow_stock;这条SQL的核心逻辑是:库存的账面值必须等于“累计进货量减去累计销售量”。如果查询结果为空,说明事务和库存联动做得是对的;如果不为空,就把不一致的商品挑出来,按时间倒序翻进货单和销售单,找到漏了哪一笔。这个验证技巧在你接手别人写的进销存项目时特别有用,不用读全部代码,先跑对账SQL就知道系统干净不干净。
验证通过之后再谈改造。如果这份JSP+Servlet+JavaBean的代码是你的课程设计,答辩时老师问“为什么不直接用Spring Boot”,你可以从这张表里找答案:JSP项目里每一次请求路径、每一次SQL调用、每一个Session操作都是显式的,你能完整说清楚Java Web运行时的请求生命周期;框架最大的价值是把重复劳动封装起来,不是替代你理解原理。如果你打算把它演进成可上线的系统,我一般建议分三步:第一步,把JDBC手写DAO换成Spring JDBC或MyBatis,让SQL和Java代码解耦;第二步,引入Maven管理依赖,把mysql-connector-java、jstl这些jar全部从lib目录里解放出来;第三步,把JSP的公共头尾抽出来,配合EL表达式和JSTL标签做模板复用。这三步做完,项目结构就会向主流的SSM靠拢,之后再迁移Spring Boot就很顺滑。我做这套系统的习惯是:每完成一个模块,先打开数据库手工跑一遍SQL验证数据,再继续下一块。宁可功能少做一个,也不要让库存和销售对不上账,希望帮到你。
本文还有配套的精品资源,点击获取