简介:这份资源是面向Java Web初学者与毕业设计学生的进销存管理系统完整项目包,采用JSP作为表现层、MSSQL作为数据库,配合Servlet与JavaBean实现三层架构,可用于课程设计参考、毕设选题落地或Java Web入门练手。压缩包共163个文件,约1.7MB,以105个class编译文件与31个java源码为主,另含4个jar依赖、8张png界面截图、mdf与ldf数据库文件及1份doc毕设文档,覆盖商品、供应商、进货、销售、库存与报表分析等模块,并附带需求分析、系统设计、测试评价等文档内容。目前已有248人学习下载。通过阅读源码与文档,读者可理清JSP页面请求如何与后端逻辑关联、JDBC如何操作MSSQL,掌握进销存业务的数据流转与表结构设计,适合作为理解Java Web开发流程与数据库交互的实践素材。
1. 从一份毕设.zip说起:JSP+MSSQL进销存到底能不能打
每年毕业季,总有一批计算机专业的学生在深夜对着一个叫“java进销存管理系统的设计与实现(jsp+mssql)包含源代码+毕设.zip”的压缩包发愁。打开一看,里面是几十个JSP文件、一堆JavaBean、一个MSSQL的.bak备份文件,还有一份Word格式的论文初稿。这个场景太典型了——它既是很多人的毕业救命稻草,也是不少人工作后回头看觉得“这代码写得真糙”的起点。但我想说的是,别急着嫌弃它。JSP+MSSQL这套组合虽然老,却恰好覆盖了进销存系统最核心的业务闭环:采购入库、销售出库、库存盘点、往来账目。对于想理解“一个业务系统怎么从数据库表设计一路做到页面交互”的人来说,这套技术栈反而比Spring Boot+MyBatis+Redis那套更容易看清全貌,因为JSP把请求响应周期暴露得足够直白,MSSQL的存储过程和触发器也能让你把业务逻辑直接焊在数据库层。这篇文章不打算复述那份论文里的需求分析,而是把这份毕设.zip拆开,讲清楚三件事:这套系统的数据模型为什么这么设计、JSP里那些看起来啰嗦的代码到底在防什么、以及如果你要把它跑起来或者改造成能用的东西,哪些参数和配置是必须动的。适合手里正拿着类似源码、想把它跑通或者想拿它当练手项目的人。
2. 进销存的数据模型:从三张核心表到MSSQL的约束设计
2.1 商品、库存、单据:为什么大部分毕设都从这三张表开始
打开任何一份进销存源码,不管前端是JSP还是Vue,数据库里一定有三张绕不开的表:商品信息表、库存表、出入库单据表。这不是偷懒,而是业务本身决定的。商品表定义“有什么”,库存表记录“有多少”,单据表回答“为什么变”。很多同学一上来就想设计得很复杂,搞出商品分类表、品牌表、供应商表、客户表、仓库表、批次表,结果做到一半发现连最基本的“查某个商品当前库存”都要关联五六张表,JSP页面里写个查询能写两百行。我的建议是:第一版只保留三张核心表,把外键关系理清楚,等跑通了再按需扩展。
以MSSQL为例,商品表通常长这样:
CREATE TABLE Product ( ProductID INT IDENTITY(1,1) PRIMARY KEY, ProductCode VARCHAR(50) NOT NULL UNIQUE, ProductName NVARCHAR(100) NOT NULL, Unit VARCHAR(20) DEFAULT '个', PurchasePrice DECIMAL(18,2) DEFAULT 0, SalePrice DECIMAL(18,2) DEFAULT 0, CreateTime DATETIME DEFAULT GETDATE() );这里有几个参数值得注意。ProductCode加了UNIQUE约束,因为进销存里商品编码重复是灾难性的,后期对账根本对不上。金额字段用DECIMAL(18,2)而不是FLOAT,浮点数在累加时会出现0.1+0.2≠0.3的经典问题,库存金额算着算着就差几分钱。CreateTime默认GETDATE()省去了Java层手动赋值,少写一行代码就少一个忘记赋值的可能。
库存表的设计更微妙。很多毕设版本直接在商品表里加一个StockQuantity字段,简单粗暴。但这样做的问题是:你无法追溯库存变化的历史。正确的做法是单独建一张库存表,并且只记录当前结存:
CREATE TABLE Inventory ( InventoryID INT IDENTITY(1,1) PRIMARY KEY, ProductID INT NOT NULL, WarehouseID INT DEFAULT 1, Quantity INT DEFAULT 0, LastUpdate DATETIME DEFAULT GETDATE(), CONSTRAINT FK_Inventory_Product FOREIGN KEY (ProductID) REFERENCES Product(ProductID) );Quantity字段用INT而不是DECIMAL,因为大多数进销存场景下商品按整数管理。如果你的业务涉及称重商品,再改成DECIMAL(18,3)。外键约束是必须加的,JSP层可以不做校验,但数据库层一定要拦住非法的ProductID插入,否则后期会出现“库存表里有记录但商品表里找不到对应商品”的脏数据。
单据表是整个系统的灵魂。采购单、销售单、退货单,本质上都是“某个商品在某个时间发生了数量变化”。所以设计一张统一的StockBill表比分开建三张表更实用:
CREATE TABLE StockBill ( BillID INT IDENTITY(1,1) PRIMARY KEY, BillType CHAR(2) NOT NULL, -- 'IN'入库 'OUT'出库 ProductID INT NOT NULL, Quantity INT NOT NULL, BillDate DATETIME DEFAULT GETDATE(), Operator VARCHAR(50), Remark NVARCHAR(200), CONSTRAINT FK_Bill_Product FOREIGN KEY (ProductID) REFERENCES Product(ProductID) );BillType用CHAR(2)存'IN'和'OUT',比用TINYINT存1和2更直观,JSP页面里判断也方便。Quantity永远存正数,方向由BillType决定,这样统计“某商品总入库量”时直接SUM(Quantity) WHERE BillType='IN'就行,不用考虑正负号。
2.2 在MSSQL里写触发器还是写Java:库存更新的两种落地路径
库存更新是进销存系统里最容易出bug的地方。假设销售出库时,JSP页面提交了一张出库单,后台需要做两件事:往StockBill表插一条记录,同时把Inventory表的Quantity减掉。这两步必须同时成功或同时失败,否则就会出现“单据有了但库存没减”或者“库存减了但单据没插进去”的灵异事件。
常见做法有两种。第一种是在Java层用JDBC事务控制:
Connection conn = null; try { conn = DriverManager.getConnection(url, user, password); conn.setAutoCommit(false); // 插入单据 String sql1 = "INSERT INTO StockBill(BillType,ProductID,Quantity,Operator) VALUES(?,?,?,?)"; PreparedStatement ps1 = conn.prepareStatement(sql1); ps1.setString(1, "OUT"); ps1.setInt(2, productId); ps1.setInt(3, quantity); ps1.setString(4, operator); ps1.executeUpdate(); // 更新库存 String sql2 = "UPDATE Inventory SET Quantity = Quantity - ? WHERE ProductID = ?"; PreparedStatement ps2 = conn.prepareStatement(sql2); ps2.setInt(1, quantity); ps2.setInt(2, productId); int rows = ps2.executeUpdate(); if (rows == 0) { throw new SQLException("库存记录不存在"); } conn.commit(); } catch (Exception e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) conn.close(); }这段代码的关键在于setAutoCommit(false)和commit/rollback的配对。很多毕设里忘记写rollback,结果出错后连接归还到连接池时还带着未提交的事务,下一个请求拿到这个连接就会看到脏数据。另外注意rows == 0的判断,如果Inventory表里根本没有这个商品的记录,UPDATE会返回0行,这时候必须抛异常回滚,否则单据插进去了但库存没动。
第二种做法是在MSSQL里写触发器:
CREATE TRIGGER trg_StockBill_Insert ON StockBill AFTER INSERT AS BEGIN SET NOCOUNT ON; UPDATE Inventory SET Quantity = CASE WHEN i.BillType = 'IN' THEN Inventory.Quantity + i.Quantity WHEN i.BillType = 'OUT' THEN Inventory.Quantity - i.Quantity END, LastUpdate = GETDATE() FROM Inventory INNER JOIN inserted i ON Inventory.ProductID = i.ProductID; END触发器的好处是Java层只需要插单据,库存自动更新,代码量少。坏处是逻辑藏在数据库里,调试时容易忘记它的存在,而且批量插入时触发器逐行执行,性能会下降。我的建议是:如果团队里有人熟悉MSSQL,用触发器;如果都是Java开发为主,老老实实在Service层用事务控制。两种方式不要混用,否则库存会被减两次。
3. JSP页面里的门道:从表单提交到分页查询的完整链路
3.1 一个入库单页面到底要写哪些JSP标签
打开毕设里的入库单页面,通常叫stock_in.jsp或者类似的名字。新手看这种页面会觉得乱,因为JSP把Java代码、HTML、JavaScript混在一起。但如果你按请求生命周期去读,其实很清晰。页面顶部一般是表单,用来输入商品编码、数量、供应商;中间是提交按钮;底部是最近入库记录列表。
表单部分的核心是这段:
<form action="StockServlet?action=add" method="post"> 商品编码:<input type="text" name="productCode" id="productCode" onblur="checkProduct()"/> <span id="productName"></span><br/> 入库数量:<input type="text" name="quantity" /><br/> 供应商:<input type="text" name="supplier" /><br/> <input type="submit" value="提交入库" /> </form>注意οnblur="checkProduct()"这个事件。进销存系统里,操作员输入商品编码后,页面应该立刻显示商品名称,防止输错编码。这个校验通常用AJAX实现:
function checkProduct() { var code = document.getElementById("productCode").value; var xhr = new XMLHttpRequest(); xhr.open("GET", "StockServlet?action=checkProduct&code=" + encodeURIComponent(code), true); xhr.onreadystatechange = function() { if (xhr.readyState == 4 && xhr.status == 200) { var resp = xhr.responseText; if (resp == "NOT_FOUND") { document.getElementById("productName").innerHTML = "<font color='red'>商品不存在</font>"; } else { document.getElementById("productName").innerHTML = resp; } } }; xhr.send(); }这里用原生XMLHttpRequest而不是jQuery,因为毕设环境里不一定有jQuery库。encodeURIComponent是必须的,商品编码里如果包含特殊字符(比如&),不编码会导致参数截断。后端StockServlet里对应处理action=checkProduct的分支,查Product表返回商品名称或"NOT_FOUND"。
提交后的处理在Servlet里:
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if ("add".equals(action)) { String productCode = request.getParameter("productCode"); int quantity = Integer.parseInt(request.getParameter("quantity")); String supplier = request.getParameter("supplier"); // 根据编码查商品ID int productId = productDao.getProductIdByCode(productCode); if (productId == -1) { request.setAttribute("error", "商品编码不存在"); request.getRequestDispatcher("stock_in.jsp").forward(request, response); return; } // 调用Service层入库 stockService.stockIn(productId, quantity, supplier); response.sendRedirect("stock_in.jsp?msg=success"); } }Integer.parseInt这里没有做异常捕获,如果用户输入了非数字,会直接抛NumberFormatException,页面显示500错误。更友好的做法是用try-catch包住,给用户提示“数量必须是数字”。另外sendRedirect后面的代码不会执行,所以不用写return,但加上return是个好习惯,防止后续维护时在重定向后面又加了代码导致逻辑混乱。
3.2 分页查询:为什么LIMIT在MSSQL里要写成ROW_NUMBER
JSP进销存系统里,库存列表、单据列表都需要分页。MySQL里用LIMIT offset, size很简单,但MSSQL(尤其是2005和2008版本)不支持LIMIT,必须用ROW_NUMBER() OVER()。这是很多从MySQL转过来的同学最容易翻车的地方。
假设每页显示20条,当前是第3页,MSSQL的写法是:
SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY BillID DESC) AS RowNum, * FROM StockBill WHERE BillType = 'IN' ) AS T WHERE T.RowNum BETWEEN 41 AND 60BETWEEN 41 AND 60对应第3页(每页20条,前两页是1-40)。ROW_NUMBER()里的ORDER BY必须和业务排序一致,否则分页会出现重复或遗漏。比如你按BillID降序排,但ROW_NUMBER()里忘了写ORDER BY,MSSQL会报错;如果写了ORDER BY BillDate但BillDate有重复值,分页时同一页可能出现两条相同记录。
对应的JSP分页控件:
<% int pageSize = 20; int currentPage = 1; String pageStr = request.getParameter("page"); if (pageStr != null) { currentPage = Integer.parseInt(pageStr); } int totalCount = (Integer) request.getAttribute("totalCount"); int totalPages = (totalCount + pageSize - 1) / pageSize; %> <div> 共 <%=totalCount%> 条记录,第 <%=currentPage%>/<%=totalPages%> 页 <% if (currentPage > 1) { %> <a href="stock_list.jsp?page=<%=currentPage-1%>">上一页</a> <% } %> <% if (currentPage < totalPages) { %> <a href="stock_list.jsp?page=<%=currentPage+1%>">下一页</a> <% } %> </div>totalPages的计算用(totalCount + pageSize - 1) / pageSize,这是整数除法的向上取整技巧。如果totalCount=41,pageSize=20,(41+19)/20=3,正确。如果直接写totalCount/pageSize,41/20=2,最后一页就丢了。
注意:MSSQL 2012及以上版本支持OFFSET FETCH,写法是ORDER BY BillID DESC OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY。但毕设环境里经常装的是SQL Server 2008 R2,所以ROW_NUMBER方案更保险。
4. 避坑与排查:JSP+MSSQL组合里最容易翻车的五个地方
4.1 中文乱码:从数据库排序规则到JSP pageEncoding的三层排查
现象:商品名称在数据库里查出来是“???”或者“锟斤拷”,JSP页面显示乱码。
原因:MSSQL的排序规则(Collation)默认可能是SQL_Latin1_General_CP1_CI_AS,不支持中文。JSP页面的pageEncoding和contentType没设对,Tomcat的URIEncoding默认不是UTF-8。
解决:分三步查。第一步,在MSSQL里执行SELECT SERVERPROPERTY('Collation'),如果结果不含Chinese_PRC,需要改数据库排序规则或者建库时指定COLLATE Chinese_PRC_CI_AS。第二步,JSP文件头部加<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>,三个属性缺一不可。第三步,Tomcat的server.xml里Connector节点加URIEncoding="UTF-8",否则GET请求里的中文参数会乱码。如果是POST请求,还需要在Servlet里加request.setCharacterEncoding("UTF-8"),且这行必须在第一次getParameter之前调用。
4.2 连接池耗尽:为什么每次刷新页面都在新建数据库连接
现象:系统跑一会儿就报“无法获取连接”,重启Tomcat后恢复,过一会儿又不行。
原因:JSP页面里直接写DriverManager.getConnection(),每次请求都新建连接,用完没有close()。MSSQL默认最大连接数有限,连接被占满后新请求就排队等死。
解决:用连接池。毕设环境里最简单的是在context.xml里配Tomcat JDBC Pool:
<Resource name="jdbc/StockDB" auth="Container" type="javax.sql.DataSource" maxTotal="20" maxIdle="10" maxWaitMillis="10000" username="sa" password="yourpassword" driverClassName="com.microsoft.sqlserver.jdbc.SQLServerDriver" url="jdbc:sqlserver://localhost:1433;databaseName=StockDB;encrypt=false"/>然后在JSP或Servlet里用DataSource ds = (DataSource) new InitialContext().lookup("java:comp/env/jdbc/StockDB");获取连接。maxTotal=20表示最多20个连接,maxWaitMillis=10000表示获取连接超时10秒。注意url里的encrypt=false,MSSQL JDBC驱动新版本默认加密,不加这个参数会报SSL错误。
4.3 金额精度丢失:DECIMAL转double的隐形陷阱
现象:采购单金额合计和手工算的对不上,差几分钱。
原因:数据库里金额是DECIMAL(18,2),Java里用double接收,double的二进制浮点表示无法精确存储0.1、0.2这样的十进制小数。累加时误差逐渐放大。
解决:Java里用BigDecimal,不要用double。从ResultSet取值时用rs.getBigDecimal("Amount"),运算时用bd1.add(bd2)而不是bd1 + bd2。如果JSP页面需要显示,用bd.setScale(2, RoundingMode.HALF_UP).toString()格式化。另外注意,BigDecimal的equals()会比较精度,1.0和1.00不相等,比较金额是否相等要用compareTo()==0。
4.4 日期格式:MSSQL的datetime和Java的java.util.Date互转
现象:入库日期在数据库里是2024-01-15 00:00:00.000,JSP页面显示成“Mon Jan 15 00:00:00 CST 2024”。
原因:直接rs.getDate()拿到的是java.sql.Date,toString()格式不符合中文习惯。或者用SimpleDateFormat格式化时pattern写错。
解决:统一用SimpleDateFormat("yyyy-MM-dd HH:mm:ss")格式化。如果只需要日期,用"yyyy-MM-dd"。注意MSSQL的datetime精度是3.33毫秒,Java的java.util.Date精度是毫秒,互转时会有微小误差,对进销存业务来说可以忽略。如果从JSP表单提交日期字符串,用new SimpleDateFormat("yyyy-MM-dd").parse(dateStr)转成Date,再new java.sql.Timestamp(date.getTime())存入数据库。
4.5 毕设源码里的硬编码:数据库密码写在JSP里怎么改
现象:源码里的数据库连接写死在每个JSP页面顶部,改密码要改几十个文件。
原因:毕设作者图省事,每个页面都复制粘贴了同一段JDBC代码。
解决:抽出一个DBUtil类,用静态方法获取连接。更规范的做法是用JNDI数据源(见4.2)。如果时间紧,至少把所有连接信息放到一个db.properties文件里:
jdbc.url=jdbc:sqlserver://localhost:1433;databaseName=StockDB;encrypt=false jdbc.username=sa jdbc.password=yourpassword然后用Properties.load()读取。注意properties文件放在WEB-INF/classes目录下,不要放在Web根目录,否则浏览器可以直接访问到,密码就泄露了。
5. 从毕设到能跑:把这份源码改造成可演示系统的三个关键动作
5.1 用MSSQL还原.bak文件并修正登录账号
毕设.zip里通常有一个StockDB.bak文件。还原步骤:打开SQL Server Management Studio,右键“数据库”->“还原数据库”->“设备”->选择.bak文件->确定。还原后数据库里可能有一个默认的登录用户,但密码未知。这时候用Windows身份验证登录,执行:
ALTER LOGIN sa WITH PASSWORD = 'NewPassword123'; ALTER LOGIN sa ENABLE;如果sa被禁用,ENABLE一下。然后在Java代码里把连接字符串的username和password改成sa和新密码。注意MSSQL默认只允许Windows身份验证,需要右键服务器->属性->安全性->选择“SQL Server和Windows身份验证模式”,重启MSSQL服务。
5.2 在IDEA里新建JSP项目并导入源码的目录结构
很多人拿到源码后不知道怎么在IDEA里跑起来。正确姿势:File->New->Project->Java Enterprise->勾选Web Application。然后把源码里的src目录下的Java文件复制到新项目的src下,WebContent或web目录下的JSP、CSS、JS复制到新项目的web下。注意WEB-INF/lib下的jar包要一起复制,通常包括mssql-jdbc.jar、jstl.jar、standard.jar。然后在Project Structure->Modules->Dependencies里确认这些jar包已加入。最后配置Tomcat运行,Application context设成/Stock,浏览器访问http://localhost:8080/Stock/login.jsp。
5.3 用SQL Server Profiler抓慢查询并加索引
系统跑起来后,如果库存列表加载超过3秒,用SQL Server Profiler跟踪一下。新建跟踪->选择Events: RPC:Completed和SQL:BatchCompleted->运行->在系统里点一下库存查询->Profiler里会显示执行的SQL和Duration。如果发现StockBill表的查询很慢,加索引:
CREATE NONCLUSTERED INDEX IX_StockBill_ProductID_BillType ON StockBill(ProductID, BillType) INCLUDE(Quantity, BillDate);这个索引覆盖了“查某商品某类型的单据”这个高频查询,INCLUDE里的字段让查询不用回表。加完索引后再用Profiler看Duration,通常能从几百毫秒降到几毫秒。注意索引不是越多越好,每个索引都会拖慢插入速度,StockBill表如果插入频繁,索引控制在3个以内。
我自己的习惯是:拿到任何一份毕设源码,先不改代码,先把它跑起来,用Profiler看一遍所有SQL,把没加索引的外键列都加上索引,然后再动业务逻辑。这样后面改代码时,至少不会因为数据库慢而怀疑是自己改错了。希望帮到你。
本文还有配套的精品资源,点击获取