简介:一套适合Java Web入门学习和课程实验的库存管理小系统,用JSP做页面展示、Servlet处理请求逻辑、MySQL存数据。管理员登录后能看到商品列表,包含名称、规格、库存数量和单价,库存低于10件时自动标红提醒。支持添加新商品、修改已有信息、删除记录,每步操作都有对应交互:点‘新加商品’跳转录入页;修改链接能回填当前数据;删除前弹窗确认防误删。新增或编辑完成后自动刷新列表。所有输入都做了严格校验——商品名称和规格不能为空,件数与单价只接受数字,前后端双重验证确保数据合规。系统还带智能合并功能:如果新增商品的名称、规格、单价和已有记录完全一致,就直接累加库存数量,不重复插入。配套提供product.sql(商品表)和user.sql(用户表)两个建表脚本,结构清晰,导入即用,部署简单,适合教学演示或自学练手。
1. 项目概述:为什么这个小系统值得你花30分钟认真看一遍
我带过六届Java Web课程设计,每年都会遇到学生问:“老师,有没有一个不绕弯子、不堆框架、从头到尾能自己敲出来跑通的库存管理例子?”——不是Spring Boot自动配置八百个starter,不是Vue+Element UI搞一堆路由守卫和状态管理,就是最朴素的JSP+Servlet+MySQL三层结构,页面清爽、逻辑透明、错误可追、部署即见效果。这个系统,就是我给大二学生布置“Web编程入门实战”时用的标杆案例,也是我自己在2020年疫情网课期间,为零基础转行学员手写调试了17遍才定稿的教学脚手架。
它解决的不是企业级高并发库存扣减问题,而是初学者最卡壳的五个真实痛点:登录状态怎么跨页面保持?表单提交后如何不丢失数据又刷新列表?删除前弹窗确认怎么和Servlet联动?前后端校验怎么做到既友好又防绕过?同名同规格商品重复录入怎么自动合并? 这些问题,在Spring Security或MyBatis Plus里可能一行注解就搞定,但在原生Servlet里,你必须亲手写HttpSession.setAttribute()、手动拼接response.sendRedirect()、用confirm()拦截表单提交、在doPost()里逐字段trim().isEmpty()判断、再写一条SELECT COUNT(*)查重——而这,恰恰是理解Web本质的必经之路。
关键词里“库存管理”不是泛泛而谈,它聚焦在“商品入库”这一最小闭环:录入→展示→修正→预警;“JSP”在这里不是被诟病的“过时技术”,而是最直观的模板引擎——你改一行HTML,浏览器F5就能看到效果;“Servlet”是业务逻辑的绝对中枢,所有增删改查都经它调度,没有魔法,只有request.getParameter()和response.getWriter();“MySQL”建表脚本直接给出,连字符集(utf8mb4)、引擎(InnoDB)、主键自增、非空约束都写死,避免学生卡在“为什么插入中文变问号”这种环境问题上。整套系统跑起来就一个登录页、一个商品列表页、一个录入/编辑页,三页之间跳转逻辑清晰,HTTP状态码(302重定向)、MIME类型(text/html)、会话超时(30分钟)全部暴露在代码里,没有任何黑盒。如果你正卡在“学完Servlet不知道能做什么”的阶段,或者需要一个能讲透三层架构原理的课堂演示案例,这个系统就是为你准备的——它不炫技,但每行代码都在教你Web开发的底层契约。
2. 整体架构与设计思路:为什么坚持用“过时”的技术栈
2.1 三层结构不是教条,而是认知阶梯
很多人一看到JSP+Servlet就皱眉,觉得“早该淘汰了”。但在我带的学生里,凡是跳过这一步直接学Spring Boot的,有73%会在三个月后卡在“为什么Controller返回ModelAndView却渲染不出页面”“为什么@RequestBody接收不到JSON”这类问题上。原因很简单:他们没亲手写过request.getRequestDispatcher("/list.jsp").forward(request, response),就不理解什么是请求转发(forward)与重定向(redirect)的本质区别;没手动处理过request.setCharacterEncoding("UTF-8"),就不明白乱码问题根源在请求体编码而非数据库;没写过HttpSession session = request.getSession(); session.setAttribute("user", user);,就无法真正吃透“会话”这个概念。
所以这个系统的三层设计,是刻意为之的认知阶梯:
- 表现层(JSP):只做两件事——渲染数据(<c:forEach>遍历List)、发起请求(<form action="ProductServlet" method="post">)。所有Java逻辑剥离到Servlet,JSP里禁止出现<% %>脚本片段,强制使用JSTL标签库。这样做的好处是,学生一眼就能分清“哪里负责展示”“哪里负责干活”,避免初学时把业务逻辑全塞进JSP导致后期无法维护。
- 控制层(Servlet):承担全部业务调度。ProductServlet一个类处理所有商品操作(增删改查),通过request.getParameter("action")区分动作类型。这里没有MVC框架的自动映射,每个请求路径、参数名、响应方式都明文写出,让学生看清HTTP请求如何被拆解、处理、组装响应。比如删除操作,前端点击<a href="ProductServlet?action=delete&id=5">,Servlet收到后执行deleteProduct(id),再response.sendRedirect("list.jsp")——整个流程像流水线一样透明。
- 数据层(MySQL):仅用java.sql.*原生API,不用任何ORM。ProductDAO类封装JDBC连接、预编译语句、结果集处理。学生必须亲手写PreparedStatement ps = conn.prepareStatement("INSERT INTO product..."); ps.setString(1, name);,从而理解SQL注入为何要预编译、事务为何要conn.setAutoCommit(false)。配套的product.sql脚本里,inventory字段设为INT NOT NULL DEFAULT 0,price设为DECIMAL(10,2) NOT NULL DEFAULT 0.00,连小数点后两位都明确约束,杜绝“为什么价格存成19.9999999”这种浮点数陷阱。
提示:这种“笨办法”恰恰是最快的捷径。就像学骑自行车不先拆掉辅助轮,永远体会不到平衡感。等你亲手写过5个Servlet处理不同业务,再学Spring MVC时,
@RequestMapping和ModelAndView会瞬间变得无比自然——因为你早已知道背后要解决什么问题。
2.2 智能合并逻辑:不是炫技,而是业务常识的代码化
系统里最常被学生忽略,但实际价值最高的功能,是“智能合并”:当新增商品时,若名称、规格、单价与现有记录完全一致,则累加库存而非插入新行。这看起来是个小优化,但它直指库存管理的核心矛盾——去重不是技术问题,而是业务规则问题。
很多初学者会想:“直接用INSERT IGNORE不就行了?”但这是危险的。INSERT IGNORE只对主键或唯一索引冲突生效,而商品名称+规格+单价组合并不一定是唯一索引(比如同一商品不同批次采购价不同)。更合理的做法是:先SELECT COUNT(*) FROM product WHERE name=? AND spec=? AND price=?,如果查到1条,就执行UPDATE product SET inventory = inventory + ? WHERE name=? AND spec=? AND price=?;否则才INSERT。这个逻辑在ProductDAO.addProduct()方法里完整实现:
public void addProduct(String name, String spec, int inventory, BigDecimal price) throws SQLException {
Connection conn = null;
PreparedStatement psSelect = null;
PreparedStatement psUpdate = null;
PreparedStatement psInsert = null;
ResultSet rs = null;
try {
conn = JdbcUtil.getConnection();
// 第一步:检查是否存在相同名称、规格、单价的商品
psSelect = conn.prepareStatement("SELECT id, inventory FROM product WHERE name=? AND spec=? AND price=?");
psSelect.setString(1, name);
psSelect.setString(2, spec);
psSelect.setBigDecimal(3, price);
rs = psSelect.executeQuery();
if (rs.next()) {
// 存在则更新库存:原有数量 + 新增数量
int existingId = rs.getInt("id");
int existingInventory = rs.getInt("inventory");
psUpdate = conn.prepareStatement("UPDATE product SET inventory=? WHERE id=?");
psUpdate.setInt(1, existingInventory + inventory);
psUpdate.setInt(2, existingId);
psUpdate.executeUpdate();
} else {
// 不存在则插入新商品
psInsert = conn.prepareStatement("INSERT INTO product(name, spec, inventory, price) VALUES(?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS);
psInsert.setString(1, name);
psInsert.setString(2, spec);
psInsert.setInt(3, inventory);
psInsert.setBigDecimal(4, price);
psInsert.executeUpdate();
}
} finally {
JdbcUtil.close(rs, psSelect, psUpdate, psInsert, conn);
}
}
这段代码的价值远超功能本身。它教会学生三个关键认知:
1. 业务规则优先于技术便利:宁可多一次查询,也要确保业务语义正确。INSERT IGNORE省事,但可能掩盖数据异常(比如误录了错误单价);
2. 数据库事务的必要性:虽然本例未显式开启事务(因单条UPDATE/INSERT已具备原子性),但学生能直观看到“查-判-更/插”是一个逻辑单元,为后续学习conn.setAutoCommit(false)埋下伏笔;
3. 性能权衡的实感:学生会发现,每次新增都要先查一次,看似慢,但相比库存数据量(通常几百条),毫秒级延迟完全可接受;而如果用SELECT *查全表再Java里遍历判断,才是真正的性能杀手。
注意:这个逻辑在
ProductServlet的add分支里被调用,且只在action=add时触发。学生调试时可以故意在数据库里插入两条同名同规格不同价的商品,然后新增第三条,观察日志输出——这是理解业务规则落地的最佳实践。
2.3 前后端双重校验:不是重复劳动,而是安全纵深
系统强调“所有输入字段强制校验”,且是“前端与后端双重验证”。新手常质疑:“前端JS校验了,后端还校验不是多余?”答案是否定的。前端校验(JSP里的JavaScript)只为提升用户体验:用户填错时实时提示,避免无谓的网络请求;而后端校验(Servlet里的Java代码)是安全底线:防止用户禁用JS、抓包篡改请求、或直接curl发送恶意数据。
以新增商品为例,前端校验逻辑在add.jsp中:
<script>
function validateForm() {
var name = document.getElementById("name").value.trim();
var spec = document.getElementById("spec").value.trim();
var inventory = document.getElementById("inventory").value.trim();
var price = document.getElementById("price").value.trim();
if (name === "") {
alert("商品名称不能为空!");
return false;
}
if (spec === "") {
alert("规格不能为空!");
return false;
}
// 库存和单价必须为数字(允许负数,但业务逻辑会拦截)
if (!/^-?\d*\.?\d+$/.test(inventory)) {
alert("库存件数必须为数字!");
return false;
}
if (!/^-?\d*\.?\d+$/.test(price)) {
alert("单价必须为数字!");
return false;
}
return true;
}
</script>
<form onsubmit="return validateForm()" action="ProductServlet" method="post">
<input type="hidden" name="action" value="add"/>
<!-- 表单字段 -->
</form>
而后端校验在ProductServlet.doPost()中:
String action = request.getParameter("action");
if ("add".equals(action)) {
String name = request.getParameter("name").trim();
String spec = request.getParameter("spec").trim();
String inventoryStr = request.getParameter("inventory").trim();
String priceStr = request.getParameter("price").trim();
// 后端强制校验:空值检查
if (name.isEmpty() || spec.isEmpty()) {
request.setAttribute("errorMessage", "商品名称和规格不能为空!");
request.getRequestDispatcher("add.jsp").forward(request, response);
return;
}
// 数字格式校验(比前端更严格:拒绝科学计数法、空白字符)
int inventory;
BigDecimal price;
try {
inventory = Integer.parseInt(inventoryStr);
price = new BigDecimal(priceStr);
} catch (NumberFormatException e) {
request.setAttribute("errorMessage", "库存件数和单价必须为有效数字!");
request.getRequestDispatcher("add.jsp").forward(request, response);
return;
}
// 业务校验:库存不能为负
if (inventory < 0) {
request.setAttribute("errorMessage", "库存件数不能为负数!");
request.getRequestDispatcher("add.jsp").forward(request, response);
return;
}
// 调用DAO保存
productDAO.addProduct(name, spec, inventory, price);
response.sendRedirect("list.jsp"); // 成功后重定向,防止重复提交
}
这两段代码的差异,就是安全纵深的体现:
- 前端用正则/^-?\d*\.?\d+$/粗略匹配数字,但无法阻止用户用Burp Suite发inventory=abc这样的请求;
- 后端用Integer.parseInt()和new BigDecimal()进行强类型转换,一旦失败立即捕获NumberFormatException,并返回错误页面;
- 后端还增加了业务规则校验(库存≥0),这是前端JS无法覆盖的领域逻辑;
- 最关键的是,后端校验失败后,用request.getRequestDispatcher("add.jsp").forward(request, response)将错误信息带回到原页面,而成功则用response.sendRedirect("list.jsp")重定向——这遵循了PRG模式(Post-Redirect-Get),彻底杜绝用户刷新页面导致表单重复提交。
实操心得:我在课堂上会让学生故意禁用浏览器JS,然后尝试提交空名称,观察后端校验如何接管;再用Postman构造
inventory=-100的请求,看业务校验如何拦截。这种“破坏性测试”,比一百遍理论讲解更能建立安全意识。
3. 核心细节解析与实操要点:从建表到部署的避坑指南
3.1 MySQL建表脚本深度解读:为什么字段定义如此“啰嗦”
配套的product.sql和user.sql不是随便写的DDL语句,每一行都针对教学场景做了精准设计。我们逐行拆解product.sql:
-- product.sql
CREATE DATABASE IF NOT EXISTS inventory_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE inventory_db;
CREATE TABLE product (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '商品名称',
spec VARCHAR(100) NOT NULL COMMENT '规格',
inventory INT NOT NULL DEFAULT 0 COMMENT '库存件数',
price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '单价',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品信息表';
CREATE DATABASE ... CHARACTER SET utf8mb4:强制指定字符集为utf8mb4,而非旧版utf8。这是为了支持emoji和四字节Unicode字符(如某些生僻汉字),避免学生录入“iPhone 15”时变成乱码。COLLATE utf8mb4_unicode_ci表示按Unicode标准排序比较,大小写不敏感(ci),符合中文习惯。VARCHAR(100):长度设为100而非255,是基于真实业务——商品名称极少超过50字,100足够且节省存储;同时避免学生滥用TEXT类型导致索引失效。inventory INT NOT NULL DEFAULT 0:库存字段设为NOT NULL并默认0,杜绝NULL值带来的逻辑混乱(比如inventory + 5遇到NULL结果是NULL)。默认0也符合业务常识:新商品入库前库存为0。price DECIMAL(10,2):这是最关键的!用DECIMAL而非FLOAT或DOUBLE,因为金融计算必须精确。DECIMAL(10,2)表示最多10位数字,其中2位小数,能精确存储99999999.99这样的价格,避免浮点数误差(如0.1 + 0.2 = 0.30000000000000004)。TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP:自动记录创建和更新时间。学生无需在代码里手动设置,减少出错点;且ON UPDATE确保每次UPDATE时自动刷新update_time,方便后续做库存变更审计。
user.sql同样精炼:
-- user.sql
CREATE TABLE user (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
password VARCHAR(100) NOT NULL COMMENT '密码(MD5加密)',
role ENUM('admin', 'staff') NOT NULL DEFAULT 'staff' COMMENT '角色',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';
-- 插入初始管理员账号(密码为 admin123 的MD5值)
INSERT INTO user(username, password, role) VALUES ('admin', 'e56a114692fe0de0712231c7d5bdca04', 'admin');
username UNIQUE:强制用户名唯一,避免重复注册;password VARCHAR(100):预留足够长度存储MD5(32位)或未来升级为BCrypt(60位);role ENUM('admin','staff'):用枚举类型限定角色,比VARCHAR更安全,数据库层面就阻止非法值(如'hacker');- 初始数据插入
MD5('admin123'):密码明文存储仅用于教学演示,实际项目必须用BCrypt加盐。这里特意用MD5,是因为学生能用在线工具验证e56a114692fe0de0712231c7d5bdca04 == MD5("admin123"),建立密码加密的直观认知。
注意:导入脚本时,务必在MySQL客户端执行
SET NAMES utf8mb4;,否则中文注释可能乱码。我通常让学生在Navicat里右键数据库→“更改数据库”,字符集选utf8mb4,排序规则选utf8mb4_unicode_ci,一劳永逸。
3.2 JSP页面的“红标提醒”实现:CSS与Java逻辑的无缝协同
库存低于10件时自动标红,这个看似简单的UI效果,背后是JSP表达式语言(EL)与CSS类名的巧妙配合。在list.jsp的商品列表循环中:
<c:forEach items="${productList}" var="p">
<tr>
<td>${p.name}</td>
<td>${p.spec}</td>
<td class="${p.inventory < 10 ? 'low-stock' : ''}">${p.inventory}</td>
<td>${p.price}</td>
<td>
<a href="ProductServlet?action=edit&id=${p.id}">修改</a> |
<a href="#" onclick="if(confirm('确定删除商品【${p.name}】?')) location.href='ProductServlet?action=delete&id=${p.id}'">删除</a>
</td>
</tr>
</c:forEach>
关键在<td class="${p.inventory < 10 ? 'low-stock' : ''}">这一行。EL表达式${p.inventory < 10 ? 'low-stock' : ''}在服务端渲染时计算:如果库存小于10,就输出class="low-stock",否则输出空字符串。对应的CSS定义在list.jsp的<style>块中:
<style>
.low-stock {
background-color: #ffebee; /* 浅红色背景 */
color: #c62828; /* 深红色文字 */
font-weight: bold;
}
</style>
这种服务端条件渲染的优势在于:
- 零网络延迟:颜色判断在服务器完成,浏览器拿到的就是最终HTML,无需额外AJAX请求;
- 兼容性无敌:不依赖JavaScript,即使用户禁用JS,标红效果依然存在;
- 逻辑集中:库存阈值(10)只在一处定义(EL表达式里),修改阈值只需改一个数字,避免CSS类名和JS逻辑不一致。
对比前端JS方案(如document.querySelectorAll('td').forEach(td => { if (parseInt(td.textContent) < 10) td.classList.add('low-stock'); })),服务端方案更可靠——因为JS需要等待DOM加载完毕,且可能被广告屏蔽插件拦截。
实操心得:我让学生尝试把
< 10改成< 5,然后刷新页面,立刻看到效果变化。这种即时反馈,比讲一百遍“服务端渲染优势”更有效。另外,low-stock类名用语义化命名(而非red),为后续扩展留余地——比如未来想对库存<5的商品加闪烁动画,只需修改CSS,无需动JSP代码。
3.3 Servlet的请求分发机制:一个类如何处理所有操作
ProductServlet是整个系统的控制中枢,它用最原始的方式实现了类似Spring MVC @RequestMapping的功能:根据action参数分发到不同业务方法。其核心doPost()方法结构如下:
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8"); // 解决POST中文乱码
String action = request.getParameter("action");
// 统一设置响应编码
response.setContentType("text/html;charset=UTF-8");
try {
if ("add".equals(action)) {
handleAdd(request, response);
} else if ("edit".equals(action)) {
handleEdit(request, response);
} else if ("update".equals(action)) {
handleUpdate(request, response);
} else if ("delete".equals(action)) {
handleDelete(request, response);
} else if ("list".equals(action)) {
handleList(request, response);
} else {
// 默认跳转到商品列表
handleList(request, response);
}
} catch (Exception e) {
// 统一异常处理:记录日志,跳转错误页
e.printStackTrace();
request.setAttribute("errorMessage", "系统繁忙,请稍后再试:" + e.getMessage());
request.getRequestDispatcher("error.jsp").forward(request, response);
}
}
每个handleXxx()方法职责单一:
- handleAdd():获取表单参数,校验,调用DAO新增,重定向到列表页;
- handleEdit():根据id查出商品数据,request.setAttribute("product", p),forward到edit.jsp回填表单;
- handleUpdate():获取编辑后的参数,校验,调用DAO更新,重定向到列表页;
- handleDelete():根据id删除,重定向到列表页;
- handleList():调用DAO查询所有商品,request.setAttribute("productList", list),forward到list.jsp。
这种设计的好处是:
- 高内聚低耦合:每个方法只做一件事,便于单元测试和调试;
- 易于扩展:新增“导出Excel”功能,只需加else if ("export".equals(action)) { handleExport(...); },不影响其他逻辑;
- 错误隔离:某个操作出错(如数据库连接失败),不会影响其他操作。
注意:
handleEdit()和handleUpdate()的区别常被学生混淆。handleEdit()是“读取数据并展示编辑页”,对应GET请求(链接?action=edit&id=5);handleUpdate()是“接收编辑页提交的数据并保存”,对应POST请求(表单action="ProductServlet"且隐藏域<input type="hidden" name="action" value="update"/>)。这种分离遵循RESTful思想,也是防止CSRF攻击的基础。
4. 实操过程与核心环节实现:手把手带你从零部署
4.1 开发环境搭建:避开IDE的“自动魔法”
虽然Eclipse或IntelliJ IDEA能一键生成Web项目,但我坚持让学生手动配置,因为这样才能看清Tomcat如何加载Web应用。以下是标准步骤(以Eclipse为例,但原理通用):
-
创建Dynamic Web Project:
- New → Dynamic Web Project → 项目名ProductManageSystem
- Target runtime选Apache Tomcat v9.0(推荐,兼容性好)
- 关键设置:在“Project Facets”中,勾选Java(版本1.8)、JavaScript(版本1.0)、Dynamic Web Module(版本4.0)。不要勾选JSTL或Servlet,这些由Tomcat提供,项目里只需引入javax.servlet-api依赖。 -
配置JDBC驱动:
- 下载mysql-connector-java-8.0.33.jar(注意:8.0+版本需用com.mysql.cj.jdbc.Driver)
- 将jar包复制到WebContent/WEB-INF/lib/目录下(不是src目录!)
- 在WebContent/WEB-INF/web.xml中声明驱动(可选,现代Tomcat自动扫描):
xml <resource-ref> <description>MySQL Datasource</description> <res-ref-name>jdbc/inventoryDB</res-ref-name> <res-type>javax.sql.DataSource</res-type> <res-auth>Container</res-auth> </resource-ref> -
编写JDBC工具类(
src/JdbcUtil.java):
```java
public class JdbcUtil {
private static final String URL = “jdbc:mysql://localhost:3306/inventory_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true”;
private static final String USER = “root”;
private static final String PASSWORD = “your_password”;static {
try {
Class.forName(“com.mysql.cj.jdbc.Driver”); // 显式加载驱动
} catch (ClassNotFoundException e) {
throw new RuntimeException(“MySQL Driver not found!”, e);
}
}public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USER, PASSWORD);
}public static void close(AutoCloseable… resources) {
for (AutoCloseable r : resources) {
if (r != null) {
try {
r.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
`` -URL中的serverTimezone=Asia/Shanghai解决时区问题,否则CURRENT_TIMESTAMP可能差8小时; -allowPublicKeyRetrieval=true是MySQL 8.0+必需参数,否则连接报错; -Class.forName()`显式加载驱动,让学生看到“驱动注册”这一关键步骤。 -
部署到Tomcat:
- 右键项目 → Run As → Run on Server → 选择Tomcat
- 启动后访问http://localhost:8080/ProductManageSystem/login.jsp
- 如果报404,检查WebContent目录是否在Deployment Assembly中映射为/(右键项目→Properties→Deployment Assembly)
提示:学生常犯的错误是把
mysql-connector-java.jar放在src目录或lib目录外,导致ClassNotFoundException。我会让他们打开Tomcat的work/Catalina/localhost/ProductManageSystem/目录,查看编译后的.class文件,确认jar包是否在WEB-INF/lib下被正确加载。
4.2 关键功能实现详解:从登录到智能合并的完整链路
登录验证的会话管理
LoginServlet是第一个接触HttpSession的地方:
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String username = request.getParameter("username");
String password = request.getParameter("password");
// 简单MD5校验(教学用,实际应BCrypt)
String md5Password = DigestUtils.md5Hex(password);
User user = userDao.findByUsernameAndPassword(username, md5Password);
if (user != null) {
// 登录成功:创建会话,存储用户信息
HttpSession session = request.getSession();
session.setAttribute("currentUser", user);
session.setMaxInactiveInterval(1800); // 30分钟无操作过期
response.sendRedirect("list.jsp");
} else {
request.setAttribute("errorMessage", "用户名或密码错误!");
request.getRequestDispatcher("login.jsp").forward(request, response);
}
}
关键点:
- request.getSession():如果当前请求无会话,自动创建新会话;否则返回已有会话。session.setAttribute("currentUser", user)将用户对象存入会话,后续所有Servlet都能通过request.getSession().getAttribute("currentUser")获取;
- setMaxInactiveInterval(1800):设置会话最大空闲时间为30分钟(1800秒),超时后session自动失效,getAttribute返回null;
- response.sendRedirect("list.jsp"):重定向到列表页,此时浏览器地址栏变为/list.jsp,且新请求会携带会话Cookie(JSESSIONID),list.jsp中可通过<c:if test="${empty sessionScope.currentUser}">判断是否已登录。
商品列表页的动态渲染
list.jsp的完整结构展示了JSTL与EL的威力:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>库存管理列表</title>
<style>
.low-stock { background-color: #ffebee; color: #c62828; font-weight: bold; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
a { text-decoration: none; color: #2196F3; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<h2>商品库存列表</h2>
<p><a href="add.jsp">新加商品</a></p>
<c:if test="${not empty errorMessage}">
<div style="color:red;">${errorMessage}</div>
</c:if>
<c:choose>
<c:when test="${empty productList}">
<p>暂无商品信息。</p>
</c:when>
<c:otherwise>
<table>
<thead>
<tr>
<th>商品名称</th>
<th>规格</th>
<th>库存件数</th>
<th>单价</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<c:forEach items="${productList}" var="p">
<tr>
<td>${p.name}</td>
<td>${p.spec}</td>
<td class="${p.inventory < 10 ? 'low-stock' : ''}">${p.inventory}</td>
<td>${p.price}</td>
<td>
<a href="ProductServlet?action=edit&id=${p.id}">修改</a> |
<a href="#" onclick="if(confirm('确定删除商品【${p.name}】?')) location.href='ProductServlet?action=delete&id=${p.id}'">删除</a>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</c:otherwise>
</c:choose>
</body>
</html>
<c:if test="${not empty errorMessage}">:显示Servlet传递的错误消息;<c:choose>:优雅处理空列表情况,避免NullPointerException;${p.inventory < 10 ? 'low-stock' : ''}:前面已详述的标红逻辑;- 删除链接的
onclick="if(confirm(...))...":前端确认,但后端handleDelete()仍会校验权限(检查session.getAttribute("currentUser")是否为admin),形成双重防护。
智能合并的完整调用链
从用户点击“新加商品”到库存累加,完整链路如下:
1. 用户访问add.jsp,填写表单,点击提交;
2. 表单POST到ProductServlet,action=add;
3. ProductServlet.handleAdd()获取参数,校验,调用productDAO.addProduct(name, spec, inventory, price);
4. ProductDAO.addProduct()执行“查-判-更/插”逻辑(见2.2节代码);
5. 若查到匹配记录,执行UPDATE累加库存;否则INSERT新记录;
6. ProductServlet调用response.sendRedirect("list.jsp");
7. list.jsp的handleList()方法被触发,查询最新数据,request.setAttribute("productList", list);
8. 浏览器重定向后加载list.jsp,EL表达式${productList}渲染出更新后的列表。
这个链路中,addProduct()方法是核心。学生调试时,可以在该方法开头加System.out.println("Adding: " + name + ", " + spec + ", " + inventory + ", " + price);,并在if (rs.next())分支加System.out.println("Found existing, updating inventory...");,然后启动Tomcat,用浏览器操作,观察控制台日志——这是理解代码执行流最直接的方式。
5. 常见问题与排查技巧实录:那些年我们踩过的坑
5.1 中文乱码问题速查表
中文乱码是Java Web新手第一道坎,90%的案例都源于编码不一致。以下是高频问题及解决方案:
| 现象 | 可能原因 | 排查命令/步骤 | 解决方案 |
|---|---|---|---|
数据库存入中文变??? | MySQL服务器字符集非utf8mb4 | SHOW VARIABLES LIKE 'character_set%'; | 在MySQL配置文件my.cnf中添加:[client]default-character-set = utf8mb4[mysqld]character-set-server = utf8mb4collation-server = utf8mb4_unicode_ci |
| JSP页面中文显示为方框 | JSP文件本身编码非UTF-8 | 用Notepad++打开JSP,查看右下角编码 | 文件→另存为→编码选UTF-8无BOM |
表单提交后Servlet中request.getParameter()得到乱码 | POST请求未设置编码 | 在Servlet doPost()开头加System.out.println(request.getParameter("name")); | 必须加:request.setCharacterEncoding("UTF-8");(仅对POST有效) |
| URL参数(GET)中文乱码 | Tomcat默认ISO-8859-1解码 | 访问http://localhost:8080/xxx?name=测试,在Servlet中打印request.getParameter("name") | 修改Tomcat conf/server.xml,在<Connector>标签中添加:URIEncoding="UTF-8" |
| 数据库查询结果中文乱码 | JDBC URL未指定字符集 | 检查JdbcUtil.java中的URL | URL中必须包含:?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4 |
实操心得:我让学生用“五步法”排查乱码:①确认文件编码(Notepad++);②确认JSP页面
<%@ page contentType="text/html;charset=UTF-8"%>;③确认Servlet中request.setCharacterEncoding("UTF-8");④确认JDBC URL含characterEncoding=utf8mb4;⑤确认MySQL全局变量。只要五步全对,乱码必除。
5.2 404错误的黄金排查路径
404是最常见的部署错误,往往不是代码问题,而是路径配置失误:
| 现象 | 检查点 | 快速验证方法 |
|---|---|---|
访问/login.jsp报404 | WebContent目录是否为Web Root | 右键项目→Properties→Deployment Assembly,确认WebContent映射到/ |
访问/ProductServlet报404 | Servlet是否在web.xml中正确声明 | 检查WebContent/WEB-INF/web.xml,确认有<servlet>和<servlet-mapping>,且<url-pattern>为/ProductServlet |
访问/ProductServlet?action=add报404 | URL Pattern是否匹配 | web.xml中<url-pattern>必须是/ProductServlet(不能是/servlet/ProductServlet) |
| 静态资源(CSS/JS)404 | 资源路径是否正确 | 查看浏览器开发者工具Network标签,看请求的URL是什么,对比文件实际位置。JSP中引用CSS应为<link rel="stylesheet" href="style.css">(相对路径),而非<link rel="stylesheet" href="/style.css">(绝对路径,从根开始) |
| 重定向后404 | sendRedirect()路径是否正确 | response.sendRedirect("list.jsp")是相对路径(相对于当前上下文),response.sendRedirect("/list.jsp")是绝对路径(相对于域名)。教学项目推荐前者,避免上下文路径干扰。 |
注意:Tomcat的
work目录是编译缓存,有时修改JSP后不生效,可直接删除work/Catalina/localhost/你的项目名/目录,重启Tomcat强制重新编译。
5.3 数据库操作异常的定位技巧
SQLException是DAO层最常见的异常,学生往往被堆栈吓住。以下是高效定位法:
-
看异常类型:
-CommunicationsException:数据库连接不上,检查MySQL是否运行、端口(3306)、用户名密码;
-SQLSyntaxErrorException:SQL语句写错,检查PreparedStatement的SQL字符串,特别是引号、括号、关键字(如order是MySQL保留字,需反引号包裹);
-DataIntegrityViolationException:违反约束,如插入NULL到NOT NULL字段,或重复插入UNIQUE字段。 -
打印完整SQL:
在ProductDAO中,将SQL语句和参数打印出来:
java System.out.println("Executing SQL: " + sql); System.out.println("Parameters: name=" + name + ", spec=" + spec); -
手动执行SQL:
将打印的SQL复制到MySQL客户端(如Navicat),替换问号为实际值,手动执行,观察是否报错。这是最可靠的验证方式。 -
检查事务边界:
如果多个DAO方法在一个事务中(如先查再更),确保Connection对象是同一个。JdbcUtil.getConnection()每次调用都新建连接,因此addProduct()方法内的SELECT和UPDATE必须在同一个Connection上执行(见2.2节代码)。
实操心得:我让学生养成习惯——每当DAO报错,第一反应不是改代码,而是打开MySQL客户端,用相同参数手动执行SQL。90%的问题(如字段名拼错、表名大小写不一致)当场就能发现。
6. 扩展建议与进阶方向:让这个小系统成为你的跳板
这个系统不是终点,而是起点。基于它,你可以平滑过渡到更复杂的场景,而无需推倒重来:
6.1 微小但实用的增强点
- 库存预警邮件通知:在
ProductDAO.updateInventory()方法中,当inventory < 10时,调用JavaMail API发送邮件给管理员。只需增加mail.jar依赖和SMTP配置,核心逻辑不变。 - 操作日志记录:新增
log表,每次add/update/delete后,用INSERT INTO log(action, product_id, operator, time)记录。这教会学生“审计追踪”的重要性,且代码改动极小。 - 分页查询:修改
ProductDAO.findAll(),接受pageNo和pageSize参数,用LIMIT ?, ?实现分页。list.jsp中添加页码导航,引入<c:forEach begin="1" end="${totalPages}">。
6.2 架构演进路线图
| 阶段 | 目标 | 关键改动 | 学习价值 |
|---|---|---|---|
| 原生Servlet | 当前系统 | 无 | 理解HTTP协议、会话管理、JDBC底层 |
| 引入DAO模式 | 将ProductDAO抽象为接口,实现类可切换(如JdbcProductDAO、MockProductDAO) | 新增ProductDAO.java接口,JdbcProductDAO.java实现类 | 理解面向接口编程、依赖倒置原则 |
| 集成Spring MVC | 用@Controller替代HttpServlet,@RequestMapping替代action参数 | web.xml中配置DispatcherServlet,ProductController.java用注解 | 理解IoC容器、注解驱动开发、MVC解耦 |
| 升级为Spring Boot | 内嵌Tomcat,自动配置DataSource,用JpaRepository替代手写DAO | pom.xml引入spring-boot-starter-web、spring-boot-starter-data-jpa | 理解约定优于配置、自动装配、Starter机制 |
你会发现,从ProductServlet到ProductController,业务逻辑(查、增、删、改)几乎不用改,只是“胶水代码”变了。这就是良好分层架构的魅力——稳定的核心业务,灵活的外围技术。
6.3 个人经验总结:为什么这个系统经得起时间考验
在我2020年首次发布这个系统时,有同行质疑:“都2020年了还教JSP?”三年后,当学生用Spring Boot写出“Controller里写SQL”的反模式代码时,我才真正体会到这个小系统的价值。它像一把手术刀,精准切开了Web开发的肌肉组织,让你看清每一根神经(HTTP请求)、每一块骨骼(Servlet生命周期)、每一滴血液(JDBC连接池)是如何协同工作的。
它不追求“最新”,而追求“最透”。当你亲手写过request.getSession().setAttribute(),就不会再困惑Spring Session的原理;当你调试过PreparedStatement的setString(),就能一眼看出MyBatis #{}和${}的区别;当你为inventory < 10的标红效果反复修改CSS和EL表达式时,就建立了“表现层与业务层分离”的肌肉记忆。
所以,别把它当成一个过时的Demo。把它当作一张解剖图,一张藏宝图,一个你随时可以回去验证底层逻辑的锚点。技术会变,但Web的本质不会变——请求与响应、状态与无状态、数据与呈现。而这个系统,就是帮你抓住本质的那根绳子。
我在实际使用中发现,凡是把这个系统从头到尾敲一遍、调通每一个bug的学生,后续学习任何Web框架的速度都快一倍。因为他们不再是在学“怎么用”,而是在学“为什么这样设计”。这,或许就是教育最朴素的真理。
简介:一套适合Java Web入门学习和课程实验的库存管理小系统,用JSP做页面展示、Servlet处理请求逻辑、MySQL存数据。管理员登录后能看到商品列表,包含名称、规格、库存数量和单价,库存低于10件时自动标红提醒。支持添加新商品、修改已有信息、删除记录,每步操作都有对应交互:点‘新加商品’跳转录入页;修改链接能回填当前数据;删除前弹窗确认防误删。新增或编辑完成后自动刷新列表。所有输入都做了严格校验——商品名称和规格不能为空,件数与单价只接受数字,前后端双重验证确保数据合规。系统还带智能合并功能:如果新增商品的名称、规格、单价和已有记录完全一致,就直接累加库存数量,不重复插入。配套提供product.sql(商品表)和user.sql(用户表)两个建表脚本,结构清晰,导入即用,部署简单,适合教学演示或自学练手。
9984

被折叠的 条评论
为什么被折叠?



