简介:一套基于JavaWeb的合同管理系统完整项目,面向JavaWeb学习者、毕业设计人员及企业信息化开发者,覆盖合同录入、查询、记录、分析与权限控制等核心流程。资源为Eclipse工程包,共941个文件、约16.18MB:JSP页面负责前端展示,Java类与Servlet处理业务逻辑,并配有数据访问接口及数据库配置文件,同时包含前端脚本、图片样式和依赖库,结构清晰便于导入运行。已有333人浏览学习,适合课程设计、企业实训或二次开发参考。压缩包内包含登录验证、图片验证码、合同增删改查、查询分析、导出Excel等源码模块,可帮助读者理解Servlet与JSP组合开发模式,提升从书本到项目的实践能力。
1. 为什么还要自己部署一个 JavaWeb 合同管理系统
公司合同管理还在靠 Excel 台账加微信群催办的话,早晚会在合同到期、续签、归档这些环节上翻车。这个 JavaWeb 合同管理系统把合同的录入、审批、查询、到期预警收进一个 Web 应用里,只要浏览器能访问,业务人员就能操作,不需要安装客户端。它的整体结构是典型的 JavaWeb 三层架构:JSP 做页面展示,Servlet 处理请求,MySQL 存数据,部署在 Tomcat 上。适合两类人:一是要在公司内网快速搭一套合同管理工具的实施人员,二是刚学完 JavaWeb、想拆一个完整项目案例来复现的开发者。下面我会按真实部署顺序,把环境配置、数据库初始化、代码走读和踩坑点一次讲透。
2. 核心功能与数据模型:合同表结构怎么设计才够用
2.1 功能边界与角色权限:先搞清楚系统管到哪一层
合同管理系统最容易犯的错是功能越做越重,最后变成半个 OA。拆这个项目时,我建议你先看它的权限模型。系统固定了三类角色:管理员、法务、普通员工。普通员工只能录入合同、查看自己发起的合同;法务负责审核;管理员拥有全部权限,包括删除、导出、分配审批人。这种设计在中小公司足够用,而且实现简单——一张 user 表加一个 role 字段,配合一个登录过滤器就能控制页面访问,不需要引入 Spring Security 那套重武器。
业务流程上,合同从录入开始进入“待审批”状态,法务审批通过后变为“生效中”,合同到期前一天系统自动把状态标记为“待续签”,管理员可以在列表页直接看到。还有一个容易被忽略的点:合同变更。系统用“变更记录”表存每次修改的字段和操作人,而不是直接覆盖主表,这样审计时能追溯是谁在什么时候改了什么字段。做合同管理,可追溯性比花哨功能重要得多。
2.2 数据库设计:五张核心表与关键字段说明
我打开项目里的sql/contract.sql脚本,核心表一共五张:用户表t_user、合同主表t_contract、合同附件表t_contract_file、审批记录表t_approval、变更记录表t_contract_log。下面是合同主表的建表脚本,字段和注释直接贴在 SQL 里:
CREATE TABLE `t_contract` ( `contract_id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '合同ID', `contract_no` VARCHAR(64) NOT NULL UNIQUE COMMENT '合同编号,业务唯一键', `contract_name` VARCHAR(128) NOT NULL COMMENT '合同名称', `party_a` VARCHAR(128) NOT NULL COMMENT '甲方(我方)', `party_b` VARCHAR(128) NOT NULL COMMENT '乙方(对方)', `amount` DECIMAL(12,2) DEFAULT 0.00 COMMENT '合同金额,单位元', `sign_date` DATE COMMENT '签订日期', `start_date` DATE NOT NULL COMMENT '生效开始日期', `end_date` DATE NOT NULL COMMENT '生效结束日期', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审批 1生效中 2已终止 3待续签', `contract_type` VARCHAR(32) DEFAULT '采购' COMMENT '合同类型:采购/销售/租赁/其他', `create_by` INT NOT NULL COMMENT '创建人ID,关联t_user', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='合同主表';这段脚本里有几个设计点值得说。合同编号contract_no设置了 UNIQUE 约束,这是为了防止并发录入时产生重复编号——在 JavaWeb 项目里,如果只靠应用层判断重复,两台 Tomcat 同时插入就会破功,数据库约束才是最后防线。status用 TINYINT 存数字状态而不是字符串,省空间而且排序方便,但代价是代码里必须写清楚状态字典,我见过不少项目死在“0 到底是待审批还是已删除”这种歧义上。
amount用 DECIMAL(12,2) 而不是 FLOAT 或 DOUBLE,这是个血泪经验。浮点数算金额会有精度丢失,1.1 加 2.2 等于 3.3000000000000003,合同金额算错了是要背责任的。日期字段拆成sign_date、start_date、end_date三个,是为了后面做到期提醒时可以直接用WHERE end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 1 DAY)这种条件扫描,不用在业务代码里做日期运算。
2.3 为什么这套结构用 JSP + Servlet 而不是 Spring Boot
现在新建 JavaWeb 项目,多数人会直接上 Spring Boot,但这个合同管理系统用的是 JSP + Servlet + JDBC 的老三样。这不是技术落后,而是这类项目有它的现实约束:部署环境可能是客户内网里一台老服务器,只装了 JDK 8 和 Tomcat 8.5,直接扔一个 WAR 包就能跑,不用配 Maven 私服、不用处理 Spring Boot 内嵌 Tomcat 和外部 Tomcat 的版本冲突。
另外,作为学习案例,JSP + Servlet 把 HTTP 请求到响应的完整链路暴露得清清楚楚:请求进来先到 Servlet 的doPost,调 Service 层,Service 调 DAO 层访问 MySQL,最后forward回 JSP 渲染。这套结构虽然代码量比 Spring Boot 大,但每一步发生了什么都能在源码里找到,排查问题不需要跳十几层封装。后面第三章我会完整演示怎么在 IDEA 里把它跑起来,包含 JDK、Tomcat、MySQL 的版本搭配和初始化脚本导入。
3. 在 IDEA 里把项目跑起来:环境版本搭配与初始化步骤
3.1 环境版本:这一步错了后面全是坑
跑起来之前先把版本对齐。我实际测试过,这个项目在下面的组合下是最稳的:JDK 1.8、Tomcat 8.5.x、MySQL 5.7 或 8.0、IDEA 2022 及以上版本。Tomcat 10 不建议用,因为它把javax.servlet换成了jakarta.servlet,而这个项目的代码里全是import javax.servlet.*,直接部署会报ClassNotFoundException,这是新手最容易掉的坑。
如果机器上已经装了多个 JDK,注意 IDEA 里要同时确认 Project Structure 和 Tomcat 的 JRE 路径是一致的。我见过的情况是:Project SDK 选了 JDK 17,Tomcat 配置里却指向 JRE 1.8,启动时控制台报UnsupportedClassVersionError,折腾半天才发现是两边版本没对齐。数据库方面,MySQL 8.0 需要把驱动换成com.mysql.cj.jdbc.Driver,5.7 则用com.mysql.jdbc.Driver,项目里默认配的是 5.7,装 8.0 的话需要改两处,后面会说到。
3.2 导入项目和数据库初始化:先建库再跑代码
打开 IDEA,选择File -> New -> Project from Existing Sources,选中项目根目录,让 IDEA 识别成 Web 项目。导入后先别急着启动,第一步是初始化数据库。用 Navicat 或命令行执行项目里的sql/contract.sql,它会自动创建数据库contract_db并写入初始数据。
mysql -u root -p < sql/contract.sql执行成功后在 MySQL 里执行USE contract_db; SHOW TABLES;,应该能看到五张表(t_user、t_contract、t_contract_file、t_approval、t_contract_log)。这一步是后面所有操作的基础——我见过有人跳过建表直接启动,结果一访问登录页就报Table 'contract_db.t_user' doesn't exist,属于典型的顺序问题。如果表已经存在想重来,先执行DROP DATABASE contract_db;再重新导入。
数据库连接配置在src/main/resources/db.properties里,默认内容如下:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/contract_db?useUnicode=true&characterEncoding=utf-8&useSSL=false jdbc.username=root jdbc.password=123456注意useSSL=false这个参数。MySQL 8.0 默认开启 SSL,如果连接串不带这个参数,控制台会刷一堆 SSL 警告,虽然不影响运行,但日志会很吵。另外characterEncoding=utf-8一定要保留,后面中文乱码那一节会专门讲。如果你是 MySQL 8.0,把驱动改成com.mysql.cj.jdbc.Driver即可。
3.3 配置 Tomcat 并启动:两步就能出界面
在 IDEA 里点Run -> Edit Configurations,新增一个 Tomcat Server -> Local。在Deployment选项卡里点加号,选择Artifact,选中项目名的war exploded包,Application context填/contract。这样访问路径就是http://localhost:8080/contract/。
# 启动前确认8080端口没有被占用 netstat -ano | grep 8080启动 Tomcat 后,浏览器访问http://localhost:8080/contract/,看到登录页说明部署成功。系统预置了两个账号:管理员admin / admin123,普通员工user / user123。这里有个小细节:项目里的登录过滤器把/login.jsp、/login和静态资源目录static/放行了,其他路径都会被拦,所以直接访问index.jsp会跳回登录页,这是预期行为。
3.4 验证环境正常:一个典型的合同录入请求链路
登录成功后,我们追踪一次合同录入请求,确认整个链路是通的。业务人员在页面上填合同表单,点提交后请求发到ContractServlet?action=add。Servlet 里先做参数校验,再调ContractService.addContract()插入主表记录,同时把附件信息写入t_contract_file表,最后response.sendRedirect("list.jsp")跳回列表页。如果你在 Servlet 的doPost里打个断点,能看到request.getParameter("contractName")拿到的值和页面表单里的 name 属性一一对应。
这一步验证的核心是“页面 → Servlet → Service → DAO → 数据库”整条链路有没有断点。如果出现了 500,点开 IDEA 控制台的异常堆栈,定位到哪个类哪个方法抛的错;如果列表页显示出来但数据是空的,优先检查数据库连接串里contract_db库名是否写对——这种问题通常不是代码逻辑,而是环境配置的锅。
4. 业务代码走读:合同 CRUD 与审批流转是怎么实现的
4.1 用 Filter 把登录态管起来:比手动判断省心十倍
整个系统的权限控制核心不在 Servlet 里,而在一个LoginFilter里。这个过滤器在web.xml中配置了拦截路径/*,每次请求进来先走它,下面是核心逻辑:
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); String uri = request.getRequestURI().replace(request.getContextPath(), ""); // 放行登录页、登录接口和静态资源 if ("/login.jsp".equals(uri) || "/login".equals(uri) || uri.startsWith("/static/")) { chain.doFilter(request, response); return; } // 会话里没有 user,说明未登录,跳回登录页 if (session == null || session.getAttribute("user") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); }为什么用 Filter 而不是在每个 Servlet 里写判断?因为合同系统里现在有合同管理、审批管理、统计分析十几个 Servlet,如果每个都写一套if (session.getAttribute("user") == null),代码重复且容易漏。过滤器把横切逻辑抽出来,新增页面时只要在web.xml里确认拦截路径就够,不需要动业务代码。
参数上注意request.getSession(false)的写法。传false表示如果没有会话就返回 null,而不是新建一个。如果写成request.getSession(),那么未登录用户访问任意页面都会被动创建一个 session,攻击者可以用这个特性做会话固定攻击,虽然在小系统里风险不大,但养成好习惯没坏处。
4.2 审批流转:用状态字段而不是流程表
这个系统的审批逻辑没有用 Activiti 那类工作流引擎,而是靠t_contract表的status字段推动。法务登录后,在待审批列表里点通过,请求到达ApprovalServlet?action=pass&contractId=23,代码逻辑如下:
public void pass(HttpServletRequest request, HttpServletResponse response) { int contractId = Integer.parseInt(request.getParameter("contractId")); Contract contract = contractDao.findById(contractId); // 只有待审批状态才能通过,防止重复审批 if (contract.getStatus() != 0) { request.setAttribute("msg", "该合同已被处理,请刷新列表"); request.getRequestDispatcher("approval_list.jsp").forward(request, response); return; } contractDao.updateStatus(contractId, 1); // 0待审批 -> 1生效中 ApprovalLog log = new ApprovalLog(); log.setContractId(contractId); log.setOperatorId(((User) request.getSession().getAttribute("user")).getId()); log.setAction("pass"); log.setRemark(request.getParameter("remark")); approvalDao.insert(log); response.sendRedirect("approval_list.jsp"); }这段代码最值得关注的是状态校验:if (contract.getStatus() != 0)。它防止了两个法务同时打开审批列表、同时点同一个合同导致重复审批。数据库层面,updateStatus的 SQL 是UPDATE t_contract SET status = 1 WHERE contract_id = ? AND status = 0,双重校验保证了并发时只有一条更新生效。这是合同管理系统里的典型并发边界,如果不做这个保护,会出现一条合同被审批两次、状态却还是生效中的脏数据。
审批记录单独落到t_approval表,而不是只更新主表状态。这样管理员在合同详情页能看到完整的审批轨迹:谁在什么时间做了通过操作、备注了什么。合同管理系统的审计要求决定了这类操作记录不能省,一旦合同出问题,第一件事就是查审批日志。
4.3 列表页分页与搜索:SQL 里的 LIMIT 和 COUNT 要一起做
合同列表页支持按名称模糊搜索和分页展示,对应ContractServlet?action=list&keyword=采购&page=1。DAO 层的实现分两步:第一步查符合条件的总条数,第二步查当前页的数据:
// 第一步:查总数,用于计算总页数 String countSql = "SELECT COUNT(*) FROM t_contract WHERE contract_name LIKE ?"; PreparedStatement ps = conn.prepareStatement(countSql); ps.setString(1, "%" + keyword + "%"); ResultSet rs = ps.executeQuery(); int total = 0; if (rs.next()) { total = rs.getInt(1); } // 第二步:查当前页数据,每页10条 String pageSql = "SELECT * FROM t_contract WHERE contract_name LIKE ? " + "ORDER BY create_time DESC LIMIT ?, 10"; ps = conn.prepareStatement(pageSql); ps.setString(1, "%" + keyword + "%"); ps.setInt(2, (page - 1) * 10);注意这里用PreparedStatement拼接参数,而不是字符串拼接 SQL。"%" + keyword + "%"是作为参数传进去的,不是拼到 SQL 文本里,这样能防 SQL 注入。如果你看到某段代码写的是"SELECT * FROM t_contract WHERE contract_name LIKE '%" + keyword + "%'",那最佳实践是立即改成预处理参数。
分页参数(page - 1) * 10是 MySQL 的LIMIT偏移量计算方式:第一页偏移 0,第二页偏移 10,第三页偏移 20。前端 JSP 页面里通过request.getAttribute("totalPages")渲染页码条,点击页码时重新携带keyword和page参数请求。搜索和分页组合在一起时,最容易翻车的点是把 keyword 丢了——点第二页发现搜索结果全没了。解决办法是翻页链接里同时带上keyword=${param.keyword},这个细节在第六章还会提到。
5. 避坑:JavaWeb 合同系统部署与使用的五条排查记录
5.1 MySQL 版本导致驱动类找不到
现象:启动项目后访问登录页,控制台报ClassNotFoundException: com.mysql.jdbc.Driver。
原因:项目默认配置文件里写的是 MySQL 5.7 的驱动类com.mysql.jdbc.Driver,但如果你装的是 MySQL 8.0,驱动类已经改名为com.mysql.cj.jdbc.Driver,旧名字在新驱动里被移除。
解决:打开db.properties,把jdbc.driver改成com.mysql.cj.jdbc.Driver,同时检查lib目录下的mysql-connector-javaJAR 包版本是不是 8.x。版本对应上之后重启 Tomcat 即可。判断装的是哪个版本,在 MySQL 命令行执行SELECT VERSION();一眼就能看出来。
5.2 中文乱码:页面显示全是问号
现象:合同名称、甲方乙方在列表中显示成???,但英文和数字正常。
原因:MySQL 连接串里没有characterEncoding=utf-8参数,或者数据库表不是 utf8 字符集。JSP 页面本身是 UTF-8,但 JDBC 连接 MySQL 时如果没指定编码,会用服务端默认的 latin1 传输,中文字符到 Java 字符串时已经损坏。
解决:三步走。第一步确认db.properties的连接串带了useUnicode=true&characterEncoding=utf-8;第二步执行SHOW CREATE TABLE t_contract;看表字符集,如果不是 utf8mb4,执行ALTER TABLE t_contract CONVERT TO CHARACTER SET utf8mb4;;第三步检查 Tomcat 的server.xml里<Connector>是否配置了URIEncoding="UTF-8",没配的话 GET 请求的中文参数也会乱。这三步做完,乱码基本能根除。
5.3 上传附件后找不到文件
现象:合同附件上传成功提示“已上传”,但在服务器磁盘上找不到对应文件。
原因:项目的上传路径写的是相对路径upload/,这个相对路径是相对于 Tomcat 进程的工作目录,也就是tomcat/bin目录,不是项目部署目录。文件被写到了tomcat/bin/upload/下面,当然找不到。
解决:打开FileUploadServlet,把保存路径改成绝对路径,比如D:/contract_upload/,或者在启动参数里指定。比较稳妥的做法是在类里加一个配置项:
// 在 web.xml 或配置文件中定义上传根目录 private static final String UPLOAD_DIR = System.getProperty("user.dir") + File.separator + "contract_upload";同时注意,下载附件时需要把存储路径同步到t_contract_file.file_path字段,否则附件列表里点击下载会 404。这类问题在本地开发时不容易暴露,因为 IDEA 的工作目录刚好在项目根下,部署到独立 Tomcat 时就翻车了。
5.4 Tomcat 端口被占用,启动直接失败
现象:启动 Tomcat 时控制台报Port 8080 is already in use,项目起不来。
原因:本机已经有别的进程占了 8080 端口,或者上一个 Tomcat 没关干净。
解决:先找到占用端口的进程:
netstat -ano | findstr 8080拿到 PID 后在任务管理器里结束进程。如果不想关掉现有服务,也可以在 IDEA 的 Tomcat 配置里把 HTTP port 改成 8081,同时在 Deploymen 里确认访问路径,访问http://localhost:8081/contract/即可。改端口后要注意如果项目代码里有写死的绝对路径http://localhost:8080,也需要同步改,否则页面跳转会 404。
5.5 登录成功后跳回登录页,形成死循环
现象:输入正确的账号密码,点登录,页面闪了一下又回到登录页。
原因:登录逻辑里session.setAttribute("user", user)没有执行成功,或者执行了但 Filter 里拿到的 session 不是同一个。常见原因是有两个 Tomcat 在跑,IDEA 用的是 8081 端口,浏览器访问的却是 8080 端口那个旧项目,两边的 session 不互通。
解决:确认浏览器访问的端口和 IDEA 启动的 Tomcat 端口一致。系统里如果有多个 Tomcat 服务,先全部停掉,只保留一个。然后在LoginServlet里打日志,确认登录成功后打印session.getId(),在 Filter 里也打印session.getId(),两个 ID 不一致就说明 session 不是同一个,重点查 Tomcat 的 context 路径配置。
6. 进阶:合同 PDF 打印与到期自动提醒两个实用改造
系统跑通之后,有两个高频需求值得优先改:合同详情页打印成 PDF、到期合同自动提醒。这两个功能直接影响业务人员的使用体验,也是这类管理系统常用的扩展点。
PDF 打印不一定要后端生成,合同详情页本质是 HTML,直接用浏览器的打印能力就能出 PDF。在contract_detail.jsp底部加一个按钮,调用window.print(),配合一段只对打印生效的 CSS:
@media print { .no-print { display: none; } body { font-size: 12px; } }把页面上操作按钮、导航栏都加上no-print类,打印时就只会输出合同主体内容,业务人员用 Chrome 的“另存为 PDF”就能导出。如果需要带骑缝章或页眉页脚,再考虑后端用 OpenPDF 生成真正的 PDF 文件,但多数内部场景不需要,浏览器打印方案零依赖、见效快。
到期提醒有两个方案。一是最省事的登录后扫描:管理员登录时,系统自动执行一条 SQL,查出三天内到期的合同,在列表顶部显示红色提醒条。另一种是定时任务:用@Scheduled或Timer每天凌晨跑一次,把到期合同生成待办写入数据库,下一次登录时展示。内网小系统用登录扫描就够了,只有合同数量大、需要主动推送时才上定时任务。
做定时任务时,不要写while(true) { Thread.sleep(86400000) }这种线程裸奔的方案,Tomcat 重载时线程会丢失,重启后提醒就断了。我后来改造成用ScheduledExecutorService配合启动时初始化,这才稳定下来。从那以后我每次做合同系统,都会在改造说明里写一条:提醒功能必须跟着应用生命周期走,别用裸线程。希望帮到你。
本文还有配套的精品资源,点击获取