☰
JSP化妆品仓储库存管理系统毕设:批次效期与SKU多规格实战
2026/9/29 2:06:07 网站建设 项目流程

简介:这份毕业设计资源面向计算机相关专业学生与Java Web初学者,提供一套基于JSP的化妆品仓储库存管理系统完整实现方案,采用B/S结构,以Java为开发语言、MySQL为后台数据库,在MyEclipse平台下开发调试,通过用户与系统管理员两种角色完成化妆品店各项信息的登记、修改与查询管理,适合用作课程设计、毕业设计参考或Java Web入门练手项目。压缩包共164个文件,约5.25MB,其中60个jsp页面构成前端交互主体,30个jpg与23个gif为界面素材,15个jar与7个class、7个java支撑后端逻辑,另有js、css、xml及工程配置文件若干,并附一份doc格式配套报告。目前已有123人学习下载。读者可从中获得可直接导入运行的工程源码、数据库脚本与设计文档,理解Servlet、过滤器、数据库连接封装等典型模块的组织方式,并参考报告中的需求分析与功能设计思路,快速搭建自己的仓储管理类项目框架。

1. 化妆品仓储库存管理系统:一个 JSP 毕设选题的真实落地路径

化妆品仓储库存管理系统的毕业设计,核心不是做一个“能跑起来的网页”,而是把批次、效期、SKU 多规格这三件事在数据库和业务层里讲清楚。很多同学拿到这个题目第一反应是去搜“jsp 入门头歌实训答案”或者“idea 新建 jsp 项目”,结果搭出来的东西只是一个能增删改查的表格页,答辩时被问一句“化妆品和普通商品库存有什么区别”就卡住了。化妆品有保质期、有批号、有套装拆合、有临期预警,这些才是这个选题真正的技术含量所在。这篇文章面向正在做基于 JSP 的毕设、或者选了 Java Web 方向但不知道从哪下手的同学,从建库建表一路讲到打包部署,中间该踩的坑我都会标出来。读完你至少能拿到一套可复现的骨架,而不是对着空白 IDEA 发呆。

2. 化妆品库存的数据模型:为什么不能照搬普通商品表

2.1 化妆品 SKU 的三个特殊字段

普通商品库存表通常就是商品 ID、名称、数量、单价。化妆品不行。同一个色号的口红,可能分滋润版和哑光版,这是规格;同一批货,生产日期不同,过期时间就不同,这是批次;一支口红可能是套装拆出来的,也可能是独立采购的,这是来源。如果只建一张product表加一个stock字段,做到后面一定会发现:查“还有多少支 3 号色没过期”这个需求根本写不出来。

我一般会拆成四张核心表:product(商品基础信息)、sku(规格维度)、batch(批次与效期)、stock_record(出入库流水)。product存品牌、品类、名称;sku存规格名、条码、参考价;batch存批次号、生产日期、到期日期、当前数量;stock_record存每一次入库或出库的操作时间、数量、操作人。这样拆的好处是,查临期商品只需要在batch表里按expire_date过滤,查某个 SKU 的总库存只需要对batch按sku_id做 SUM。

下面是我在 MySQL 里建表的 SQL,字段类型和索引都按化妆品场景调过:

CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, brand VARCHAR(64) NOT NULL COMMENT '品牌名', category VARCHAR(32) NOT NULL COMMENT '品类:护肤/彩妆/香水', name VARCHAR(128) NOT NULL COMMENT '商品名称', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sku ( id INT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL, spec_name VARCHAR(64) NOT NULL COMMENT '规格:3号色/滋润版', barcode VARCHAR(32) UNIQUE, ref_price DECIMAL(10,2) COMMENT '参考价', FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE batch ( id INT PRIMARY KEY AUTO_INCREMENT, sku_id INT NOT NULL, batch_no VARCHAR(32) NOT NULL COMMENT '生产批号', produce_date DATE NOT NULL, expire_date DATE NOT NULL, quantity INT NOT NULL DEFAULT 0, INDEX idx_expire (expire_date), INDEX idx_sku (sku_id), FOREIGN KEY (sku_id) REFERENCES sku(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE stock_record ( id INT PRIMARY KEY AUTO_INCREMENT, batch_id INT NOT NULL, change_type TINYINT NOT NULL COMMENT '1入库 2出库', change_qty INT NOT NULL, operator VARCHAR(32), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (batch_id) REFERENCES batch(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:batch表上的idx_expire索引是给临期预警查询用的,没有这个索引,当批次数据到几千条时,WHERE expire_date < DATE_ADD(NOW(), INTERVAL 30 DAY)会走全表扫描。stock_record不直接关联sku而是关联batch,因为库存变动的真实对象是批次,不是规格。参数上,change_type用 TINYINT 而不是 ENUM,是为了在 JSP 页面里做下拉框时更好映射,1 对应入库、2 对应出库,前端传值不容易出错。

2.2 在 IDEA 里新建 JSP 项目并连上数据库

热词里“idea 新建 jsp 项目”出现频率很高,说明很多人卡在第一步。IDEA 旗舰版可以直接建 Java Enterprise 项目,社区版没有这个选项,需要手动建 Maven 项目再补 Web 目录。我一般用 Maven 的 webapp 骨架,省得自己配目录结构。

mvn archetype:generate -DgroupId=com.cosmetic -DartifactId=stock-system \ -DarchetypeArtifactId=maven-archetype-webapp -DinteractiveMode=false

生成后在pom.xml里加三个依赖:javax.servlet-api(provided 范围)、mysql-connector-java、jstl。注意 servlet-api 的 scope 必须写 provided,否则打包 war 时会把容器自带的 servlet 类也打进去,Tomcat 启动直接报类冲突。

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>

数据库连接我建议写一个DBUtil类,用静态块加载驱动,不要在每个 Servlet 里都写一遍DriverManager.getConnection。连接参数里serverTimezone=Asia/Shanghai必须加,MySQL 8 的驱动不配这个,插入DATETIME字段会报时区错误。useSSL=false在本地开发时加上,省得控制台一直刷警告。

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/cosmetic_stock?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PWD = "your_password"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL驱动加载失败", e); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } }

参数说明:characterEncoding=utf8要和建表时的utf8mb4配合,否则化妆品名称里的特殊字符(比如某些品牌名带重音符号)会变成问号。com.mysql.cj.jdbc.Driver是 MySQL 8 的驱动类名,如果你用的是 5.x 的 jar 包,类名是com.mysql.jdbc.Driver,写错了启动就报ClassNotFoundException。

3. 入库出库与临期预警:JSP 页面怎么和业务逻辑串起来

3.1 入库 Servlet 的完整写法与事务边界

入库操作不是简单地在batch表里加一个数字。正确流程是:先查这个 SKU 下是否已有相同批号的批次,有就更新数量,没有就新建批次,然后往stock_record里插一条流水。这三步必须在一个事务里,否则可能出现批次数量加了但流水没记上,对账时就是一笔糊涂账。

protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int skuId = Integer.parseInt(req.getParameter("skuId")); String batchNo = req.getParameter("batchNo"); int qty = Integer.parseInt(req.getParameter("qty")); String produceDate = req.getParameter("produceDate"); String expireDate = req.getParameter("expireDate"); Connection conn = null; try { conn = DBUtil.getConn(); conn.setAutoCommit(false); // 开启事务 // 1. 查是否已有同批号批次 String checkSql = "SELECT id, quantity FROM batch WHERE sku_id=? AND batch_no=? FOR UPDATE"; PreparedStatement ps = conn.prepareStatement(checkSql); ps.setInt(1, skuId); ps.setString(2, batchNo); ResultSet rs = ps.executeQuery(); int batchId; if (rs.next()) { batchId = rs.getInt("id"); int newQty = rs.getInt("quantity") + qty; PreparedStatement update = conn.prepareStatement( "UPDATE batch SET quantity=? WHERE id=?"); update.setInt(1, newQty); update.setInt(2, batchId); update.executeUpdate(); } else { PreparedStatement insert = conn.prepareStatement( "INSERT INTO batch(sku_id,batch_no,produce_date,expire_date,quantity) VALUES(?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS); insert.setInt(1, skuId); insert.setString(2, batchNo); insert.setDate(3, Date.valueOf(produceDate)); insert.setDate(4, Date.valueOf(expireDate)); insert.setInt(5, qty); insert.executeUpdate(); ResultSet keys = insert.getGeneratedKeys(); keys.next(); batchId = keys.getInt(1); } // 2. 写流水 PreparedStatement record = conn.prepareStatement( "INSERT INTO stock_record(batch_id,change_type,change_qty,operator) VALUES(?,1,?,?)"); record.setInt(1, batchId); record.setInt(2, qty); record.setString(3, (String) req.getSession().getAttribute("username")); record.executeUpdate(); conn.commit(); resp.sendRedirect("stock_list.jsp"); } catch (Exception e) { if (conn != null) try { conn.rollback(); } catch (SQLException ex) {} throw new ServletException("入库失败", e); } finally { if (conn != null) try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ex) {} } }

逻辑说明:SELECT ... FOR UPDATE是行锁,防止两个操作员同时入库同一个批次时数量覆盖。setAutoCommit(false)之后的每一步如果抛异常,rollback会把批次更新和流水插入一起撤销。参数上,Date.valueOf()要求前端传的日期格式是yyyy-MM-dd,JSP 页面里的<input type="date">默认就是这个格式,不用额外转换。operator从 session 里取,所以登录功能必须先做好,否则这里会插 null。

3.2 临期预警查询与 JSP 页面渲染

临期预警是这个选题最容易被答辩老师问到的功能点。实现方式很简单:在batch表里查expire_date在当前日期到未来 30 天之间的记录,并且quantity > 0。查询本身不复杂,关键是 JSP 页面怎么把结果渲染得清楚。

public List<Map<String, Object>> getExpiringBatches(int days) throws SQLException { String sql = "SELECT b.batch_no, b.expire_date, b.quantity, s.spec_name, p.name, p.brand " + "FROM batch b " + "JOIN sku s ON b.sku_id = s.id " + "JOIN product p ON s.product_id = p.id " + "WHERE b.expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) " + "AND b.quantity > 0 " + "ORDER BY b.expire_date ASC"; try (Connection conn = DBUtil.getConn(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, days); ResultSet rs = ps.executeQuery(); List<Map<String, Object>> list = new ArrayList<>(); while (rs.next()) { Map<String, Object> row = new HashMap<>(); row.put("batchNo", rs.getString("batch_no")); row.put("expireDate", rs.getDate("expire_date")); row.put("quantity", rs.getInt("quantity")); row.put("specName", rs.getString("spec_name")); row.put("productName", rs.getString("name")); row.put("brand", rs.getString("brand")); list.add(row); } return list; } }

参数说明:days是预警天数,我一般设 30,但化妆品里护肤品的保质期通常是 3 年,彩妆是 3 到 5 年,所以 30 天预警对化妆品来说偏短,实际做毕设时可以设成 90 天,答辩时也更好讲。BETWEEN CURDATE() AND DATE_ADD(...)会把今天到期的也算进去,如果不想包含已过期的,把CURDATE()换成DATE_ADD(CURDATE(), INTERVAL 1 DAY)。

JSP 页面用 JSTL 的<c:forEach>遍历:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table border="1"> <tr><th>品牌</th><th>商品</th><th>规格</th><th>批号</th><th>到期日</th><th>数量</th></tr> <c:forEach items="${expiringList}" var="item"> <tr> <td>${item.brand}</td> <td>${item.productName}</td> <td>${item.specName}</td> <td>${item.batchNo}</td> <td>${item.expireDate}</td> <td>${item.quantity}</td> </tr> </c:forEach> </table>

注意:JSTL 的uri在 Tomcat 10 里变成了jakarta.tags.core,如果你用的是 Tomcat 9 及以下,写http://java.sun.com/jsp/jstl/core没问题。Tomcat 10 换了命名空间,老写法直接报 500。这是“传统 jsp 项目打包 war”时最容易翻车的地方,后面避坑章节会细说。

4. 避坑与排查:JSP 毕设里最容易翻车的五个地方

4.1 中文乱码:从表单到数据库一路问号

现象:JSP 页面输入“兰蔻小黑瓶”,提交后在列表页显示成“???”。原因通常有三层:JSP 页面本身的pageEncoding没设、请求体编码没设、数据库连接串没设characterEncoding。解决方式是三处都检查。JSP 头部写<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;Servlet 里在doPost第一行写req.setCharacterEncoding("UTF-8");JDBC URL 里加characterEncoding=utf8。三处缺一处都可能乱码,我一般三个都写上,不给自己留后悔药。

4.2 Tomcat 10 下 JSTL 标签不解析

现象:页面直接输出${item.brand}原文,或者报The absolute uri: http://java.sun.com/jsp/jstl/core cannot be resolved。原因是 Tomcat 10 把 Java EE 的命名空间从javax.*换成了jakarta.*,JSTL 的 uri 也跟着变了。解决方式有两种:要么把 Tomcat 换成 9.x,要么把 JSTL 依赖换成jakarta.servlet.jsp.jstl的 2.0 版本,页面 uri 改成jakarta.tags.core。毕设环境我建议直接用 Tomcat 9,省去这一堆兼容问题,答辩前不用折腾。

4.3 打包 war 后 404:webapp 目录结构不对

现象:IDEA 里跑得好好的,mvn package打出 war 丢进 Tomcat 的webapps目录,访问就是 404。原因通常是 Maven webapp 骨架生成的webapp目录下没有WEB-INF/web.xml,或者web.xml的metadata-complete设成了true导致注解扫描失效。解决方式是确认src/main/webapp/WEB-INF/web.xml存在,并且web-app标签的version和metadata-complete属性不要乱设。如果 Servlet 用的是@WebServlet注解,metadata-complete必须是false或者不写。

4.4 数据库连接池没配导致连接耗尽

现象:连续操作几次后页面卡死,控制台报Too many connections。原因是每次请求都DriverManager.getConnection新建连接,用完只close了 ResultSet 和 Statement,Connection 忘了关。解决方式是在finally块里确保conn.close()执行,或者引入 Druid、HikariCP 连接池。毕设规模不大,用连接池有点重,但至少要把close写对。我一般用 try-with-resources 包住 Connection,少写 finally,不容易漏。

4.5 日期格式转换异常

现象:入库时填2025-01-15,后台报IllegalArgumentException: Timestamp format must be yyyy-mm-dd hh:mm:ss。原因是前端传的日期字符串直接塞给了PreparedStatement.setDate,而setDate要求java.sql.Date类型。解决方式是先用java.sql.Date.valueOf(dateStr)转一下,再setDate。如果前端传的是2025/01/15这种带斜杠的格式,valueOf会直接抛异常,需要在页面端用<input type="date">保证格式统一。

5. 从能跑到能答辩:三个让毕设加分的技术细节

5.1 用拦截器做登录校验,别在每个 JSP 里写 session 判断

很多同学的毕设是每个 JSP 页面开头写一段<% if(session.getAttribute("user")==null) response.sendRedirect("login.jsp"); %>。这样写能跑,但答辩时老师一看就知道是拼凑的。更好的做法是写一个LoginFilter,在web.xml里配url-pattern为/*,排除login.jsp和login这个 Servlet 路径。这样所有需要登录的页面自动被拦截,代码干净,也方便讲“过滤器链”这个概念。

@WebFilter("/*") public class LoginFilter implements Filter { private static final List<String> WHITE_LIST = Arrays.asList("/login.jsp", "/login", "/css/", "/js/"); public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String path = request.getRequestURI().substring(request.getContextPath().length()); boolean isWhite = WHITE_LIST.stream().anyMatch(path::startsWith); if (isWhite || request.getSession().getAttribute("user") != null) { chain.doFilter(req, resp); } else { response.sendRedirect(request.getContextPath() + "/login.jsp"); } } }

参数说明:WHITE_LIST里放的是不需要登录就能访问的路径,/css/和/js/是静态资源,不排除的话登录页的样式加载不出来。path::startsWith是 Java 8 的方法引用,判断路径是否以白名单里的字符串开头。这个 Filter 用@WebFilter注解注册,不需要在web.xml里再配一遍,但前提是web.xml的metadata-complete不能是true。

5.2 库存扣减用乐观锁,避免超卖

出库操作和入库类似,但多了一个并发问题:两个操作员同时出库同一个批次,可能把数量扣成负数。解决方式是在UPDATE语句里加条件AND quantity >= ?,然后判断executeUpdate()的返回值。如果返回 0,说明库存不足,回滚事务并提示用户。

String sql = "UPDATE batch SET quantity = quantity - ? WHERE id = ? AND quantity >= ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, outQty); ps.setInt(2, batchId); ps.setInt(3, outQty); int affected = ps.executeUpdate(); if (affected == 0) { conn.rollback(); throw new RuntimeException("库存不足,出库失败"); }

逻辑说明:AND quantity >= ?是乐观锁的核心,数据库在执行 UPDATE 时会检查当前数量是否足够,不够就不更新,返回影响行数 0。这种方式比先查再改更安全,因为查和改之间没有间隙,不会被其他事务插入。参数上,三个?分别对应扣减数量、批次 ID、扣减数量,第三个和第一个值相同,但作用不同,一个是 SET 里的减数,一个是 WHERE 里的阈值。

5.3 答辩时怎么讲这个系统的技术含量

答辩老师不会在意你的页面好不好看,他们在意的是你有没有理解“库存”这两个字背后的数据一致性。我建议在答辩时重点讲三件事:第一,批次和 SKU 的拆分逻辑,为什么化妆品必须按批次管理;第二,入库和出库的事务边界,为什么流水表和批次表要一起提交;第三,临期预警的查询索引,为什么expire_date上要建索引。这三件事讲清楚,比展示十个页面都有用。

另外,如果老师问“为什么用 JSP 不用 Spring Boot”,不要慌。可以说 JSP 是 Java Web 的基础,理解 Servlet 生命周期和 JSP 九大内置对象对后续学框架有帮助,而且毕设周期短,JSP 加 Servlet 加 JDBC 的技术栈足够覆盖库存管理的核心逻辑。这个回答既诚实又体现了你对技术选型的思考。

最后说一个我自己的习惯:每次改完代码,先mvn clean package打一次 war,丢到 Tomcat 里跑一遍,确认没问题再提交。IDEA 里热部署有时候会骗你,明明改了代码但跑的还是旧类,这种玄学问题在答辩前遇到一次就够你受的。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询