news 2026/11/3 8:50:48

JavaWeb合同管理系统实战:JSP+Servlet+MySQL搭建企业级合同管理平台

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaWeb合同管理系统实战:JSP+Servlet+MySQL搭建企业级合同管理平台

简介:本资源是一套基于JavaWeb技术开发的合同管理系统完整源码工程,面向Java初学者与Web开发入门者,解决企业级合同信息管理、查询、执行跟踪与统计分析等实际业务问题。项目采用Servlet+JSP+JSTL架构,集成MySQL数据库,涵盖用户登录、合同CRUD、权限控制、Excel导出、到期提醒等核心模块,适合作为课程设计、毕业设计或实训项目参考。压缩包共941个文件,含253个HTML页面、203个CSS样式、157个PNG图标、103个JS脚本、24个JSP动态页及35个JAR依赖库,整体16.18MB,结构清晰,前后端分离明确,便于理解MVC分层逻辑与Web交互流程。内容预览显示包含ContractAction、UserInfoDAO、ExportExcelUtil等关键类,体现典型DAO层封装与工具类复用设计。目前已有333人学习下载,可直接部署运行,快速掌握JavaWeb企业应用开发全流程。

1. 合同管理系统新版:为什么一个“老派”的 JavaWeb 项目,现在反而成了中小企业的刚需落地选择?

你可能刚在招聘网站上刷到「急招 Java 开发,需有合同管理系统开发经验」;也可能被销售拉着看某 SaaS 合同平台演示,最后卡在「定制字段要加钱」「流程引擎不支持三级会签」;更可能是在公司用着 Excel 管合同,直到法务部拿着一份漏了盖章、超期未续的采购合同找上门——才发现:合同不是签完就完事,而是履约起点、风险源头、数据金矿。

这个标题里的「合同管理系统新版_javaweb_合同系统WEB」,不是某个神秘开源项目代号,而是国内大量中型制造、工程服务、区域律所真实在跑的一类系统:它不用 Spring Boot 自动装配炫技,不强求微服务拆分,但必须能离线部署在客户内网服务器上、能对接本地 OA 审批流、能导出符合《电子签名法》要求的 PDF 带时间戳和骑缝章、能按「甲方/乙方/合同类型/履约状态/到期日」五维交叉筛选——而这些,恰恰是纯云 SaaS 难以覆盖的硬边界。

它适合三类人:一是刚从培训班出来、手上有 JSP+Servlet+MySQL 基础想快速做出可演示项目的应届生;二是给本地客户做定制化交付的外包团队,需要一套稳定、易改、文档齐全的基线代码;三是企业 IT 部门里那个总被业务催着“先搭个能用的版本”的工程师——你要的不是 Demo,是明天就能让法务同事录入第一份合同、财务同事查到付款节点、老板在后台看到合同总金额与回款率的系统。本文就带你从零搭起这个「新版」:不绕开 Filter 拦截器写法,不跳过 JDBC 手动事务控制,不假装 Tomcat 8 和 MySQL 5.7 不存在。我们只做一件事:让合同管理这件事,在 Web 端真正「落得下、管得住、查得清」。


2. 用 JSP + Servlet + MySQL 搭建合同管理最小可行系统:从数据库建模到首页渲染

合同系统不是 CRUD 的简单堆砌。它有强业务语义:一份合同必然关联甲方、乙方、签约人、归档部门;必然有「草稿→待审批→已签署→履行中→已终止」的状态机;必然存在「主合同-补充协议」的父子关系;必然要支持附件上传(扫描件、签字页、资质文件)。这些,决定了数据库设计不能拍脑袋。

2.1 数据库表结构设计:避开「一张大表全包」的血泪坑

我见过太多新手把合同字段全塞进t_contract表:甲方名称、乙方名称、签约人电话、联系邮箱、付款方式、发票类型……结果半年后加个「履约保证金比例」字段,整张表锁表 3 分钟,业务系统直接告警。正确做法是垂直拆分 + 状态驱动:

-- 核心合同主表(轻量,高频查询) CREATE TABLE t_contract ( id BIGINT PRIMARY KEY AUTO_INCREMENT, contract_no VARCHAR(32) NOT NULL UNIQUE COMMENT '合同编号,如 HT-2024-001', title VARCHAR(200) NOT NULL COMMENT '合同标题', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-草稿,1-待审批,2-已签署,3-履行中,4-已终止,5-已作废', contract_type TINYINT NOT NULL COMMENT '1-采购,2-销售,3-服务,4-劳务,5-其他', amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '合同金额(元)', sign_date DATE COMMENT '签署日期', start_date DATE COMMENT '生效日期', end_date DATE COMMENT '终止日期', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, creator_id BIGINT NOT NULL ); -- 合同主体表(解耦甲方/乙方信息,支持多主体) CREATE TABLE t_contract_party ( id BIGINT PRIMARY KEY AUTO_INCREMENT, contract_id BIGINT NOT NULL, party_type TINYINT NOT NULL COMMENT '1-甲方,2-乙方', org_name VARCHAR(100) NOT NULL COMMENT '单位全称', legal_rep VARCHAR(50) COMMENT '法定代表人', address TEXT COMMENT '注册地址', phone VARCHAR(20), bank_name VARCHAR(100) COMMENT '开户行', bank_account VARCHAR(50) COMMENT '银行账号', tax_id VARCHAR(50) COMMENT '统一社会信用代码', is_main TINYINT DEFAULT 0 COMMENT '是否为主主体(一份合同仅一个主甲方/主乙方)' ); -- 合同附件表(独立存储,避免 BLOB 拖慢主表) CREATE TABLE t_contract_attachment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, contract_id BIGINT NOT NULL, file_name VARCHAR(200) NOT NULL COMMENT '原始文件名', file_path VARCHAR(300) NOT NULL COMMENT '服务器相对路径,如 /uploads/ht/2024/HT-2024-001_签字页.pdf', file_size INT NOT NULL COMMENT '字节数', file_type VARCHAR(20) NOT NULL COMMENT 'MIME 类型,如 application/pdf', upload_time DATETIME DEFAULT CURRENT_TIMESTAMP, uploader_id BIGINT NOT NULL );

提示:contract_no字段必须设为UNIQUE,这是后续所有业务操作(如合同查询、PDF 生成、归档编号)的锚点。不要依赖自增 ID 做对外展示编号——业务人员永远记不住id=12345,但能一眼认出HT-2024-001。

2.2 Servlet 层实现合同列表与详情:状态过滤与权限隔离

合同列表页不是简单SELECT * FROM t_contract。它必须默认只显示「当前用户创建的 + 状态非草稿」的合同,并支持按状态、类型、时间范围筛选。关键逻辑在ContractListServlet中:

// java/com/example/contract/servlet/ContractListServlet.java protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 获取当前登录用户(从 Session 中取,实际项目应走统一认证 Filter) User currentUser = (User) req.getSession().getAttribute("user"); if (currentUser == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } // 2. 构建动态 SQL 条件(避免 SQL 注入!用 PreparedStatement) StringBuilder sql = new StringBuilder("SELECT c.*, p.org_name as party_name FROM t_contract c "); sql.append("LEFT JOIN t_contract_party p ON c.id = p.contract_id AND p.is_main = 1 "); sql.append("WHERE c.creator_id = ? AND c.status != 0 "); // 排除草稿 List<Object> params = new ArrayList<>(); params.add(currentUser.getId()); // 3. 动态追加查询条件(来自 request.getParameter) String status = req.getParameter("status"); if (StringUtils.isNotBlank(status)) { sql.append(" AND c.status = ? "); params.add(Integer.parseInt(status)); } String keyword = req.getParameter("keyword"); if (StringUtils.isNotBlank(keyword)) { sql.append(" AND (c.title LIKE ? OR c.contract_no LIKE ?) "); params.add("%" + keyword.trim() + "%"); params.add("%" + keyword.trim() + "%"); } // 4. 执行查询(使用 DbUtil 工具类封装 JDBC) List<Contract> contractList = DbUtil.query(sql.toString(), Contract.class, params.toArray()); // 5. 设置请求属性并转发到 JSP req.setAttribute("contractList", contractList); req.getRequestDispatcher("/WEB-INF/jsp/contract/list.jsp").forward(req, resp); }

参数说明:

  • currentUser.getId()是登录态校验的基石,没有这一步,任何「我的合同」功能都是空中楼阁;
  • c.status != 0过滤草稿,是因为草稿合同不应出现在业务人员常规视图中(草稿仅对创建者可见,且需单独入口);
  • LIKE ?使用?占位符而非字符串拼接,是防止 SQL 注入的底线要求,JDBC 驱动会自动转义单引号等危险字符;
  • LEFT JOIN ... is_main = 1确保每条合同记录只关联一个主主体(甲方或乙方),避免列表页因一对多产生重复行。

2.3 JSP 页面渲染:用 JSTL + EL 实现无 JS 的清爽列表

很多新手一上来就写 jQuery AJAX 刷新列表,结果调试跨域、处理 loading 状态、兼容 IE11 花掉三天。其实合同列表这种低频操作,用传统表单提交 + JSP 渲染更稳、更快、更易调试:

<!-- /WEB-INF/jsp/contract/list.jsp --> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <!DOCTYPE html> <html> <head> <title>合同列表</title> <link rel="stylesheet" href="${pageContext.request.contextPath}/static/css/bootstrap.min.css"> </head> <body> <div class="container mt-4"> <h2>我的合同</h2> <!-- 搜索表单 --> <form class="row mb-3" method="get"> <div class="col-md-3"> <input type="text" name="keyword" class="form-control" placeholder="合同编号或标题" value="${param.keyword}"> </div> <div class="col-md-2"> <select name="status" class="form-control"> <option value="">全部状态</option> <c:forEach items="${statusMap}" var="entry"> <option value="${entry.key}" ${param.status == entry.key ? 'selected' : ''}> ${entry.value} </option> </c:forEach> </select> </div> <div class="col-md-2"> <button type="submit" class="btn btn-primary">搜索</button> <a href="${pageContext.request.contextPath}/contract/add.jsp" class="btn btn-success ms-2">新建</a> </div> </form> <!-- 合同表格 --> <table class="table table-striped"> <thead> <tr> <th>编号</th> <th>标题</th> <th>甲方/乙方</th> <th>金额(元)</th> <th>状态</th> <th>签署日期</th> <th>操作</th> </tr> </thead> <tbody> <c:choose> <c:when test="${empty contractList}"> <tr><td colspan="7" class="text-center text-muted">暂无合同</td></tr> </c:when> <c:otherwise> <c:forEach items="${contractList}" var="c"> <tr> <td>${c.contractNo}</td> <td>${c.title}</td> <td>${c.partyName}</td> <td class="text-end"><fmt:formatNumber value="${c.amount}" pattern="#,##0.00"/></td> <td> <span class="badge bg-${c.status == 2 ? 'success' : c.status == 3 ? 'info' : 'warning'}"> ${statusMap[c.status]} </span> </td> <td>${c.signDate}</td> <td> <a href="${pageContext.request.contextPath}/contract/detail?id=${c.id}" class="btn btn-sm btn-outline-primary">查看</a> <c:if test="${c.status == 0 || c.status == 1}"> <a href="${pageContext.request.contextPath}/contract/edit?id=${c.id}" class="btn btn-sm btn-outline-secondary">编辑</a> </c:if> </td> </tr> </c:forEach> </c:otherwise> </c:choose> </tbody> </table> </div> </body> </html>

关键细节:

  • ${statusMap}是在 Servlet 中通过req.setAttribute("statusMap", getStatusMap())传入的Map<Integer, String>,将数字状态码映射为中文(如2 → "已签署"),避免 JSP 里写c.status == 2 ? "已签署" : ...这种硬编码;
  • <fmt:formatNumber>标签用于金额千分位格式化,需在 JSP 顶部声明<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>;
  • 操作列的按钮显隐逻辑:只有状态为「草稿」或「待审批」时才显示「编辑」,体现状态机约束——已签署合同不可修改正文,只能走补充协议流程。

3. 合同状态流转与审批流嵌入:用 Filter + Session 实现轻量级流程控制

合同系统的核心价值,不在存,而在「管」。而「管」的实质,是状态变更的受控、可追溯、可干预。很多项目把审批流做成独立模块,结果合同主表和审批表耦合严重,一个字段改错,整个流程崩掉。我们的做法是:用 Filter 拦截关键操作,用 Session 存储临时审批上下文,用数据库字段记录最终状态——不引入 Activiti 等重型引擎,也能跑通真实业务。

3.1 审批状态机定义与数据库字段联动

先明确合同生命周期中哪些节点需要人工干预:

当前状态可触发动作目标状态触发条件
草稿提交审批待审批创建者点击「提交」
待审批部门负责人审批通过已签署审批人点击「同意」且上传签字页
待审批部门负责人驳回草稿审批人点击「驳回」并填写理由
已签署发起履约检查履行中法务确认合同生效
履行中触发付款节点(状态不变,更新付款记录)财务录入付款信息

关键点:状态变更必须伴随操作人、时间、理由(可选)的完整记录。因此,我们在t_contract表中增加三个字段:

ALTER TABLE t_contract ADD COLUMN last_operator_id BIGINT COMMENT '最后操作人ID', ADD COLUMN last_operation_time DATETIME COMMENT '最后操作时间', ADD COLUMN last_operation_desc VARCHAR(200) COMMENT '最后操作描述,如"提交审批"、"法务确认生效"';

3.2 编写 ContractOperationFilter:拦截 /contract/approve 和 /contract/reject 请求

Filter 是 JavaWeb 中最被低估的组件。它不侵入业务逻辑,却能在请求到达 Servlet 前完成鉴权、日志、状态校验。针对审批操作,我们写一个专用 Filter:

// java/com/example/contract/filter/ContractOperationFilter.java public class ContractOperationFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String uri = req.getRequestURI(); // 只拦截审批相关路径 if (!uri.contains("/contract/approve") && !uri.contains("/contract/reject")) { chain.doFilter(request, response); return; } // 1. 登录态校验 User user = (User) req.getSession().getAttribute("user"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } // 2. 获取合同 ID(从 URL 或 Form 中提取) Long contractId = null; if ("GET".equalsIgnoreCase(req.getMethod())) { contractId = Long.parseLong(req.getParameter("id")); } else { // POST 表单提交,从 request body 解析(此处简化为 getParameter) contractId = Long.parseLong(req.getParameter("contractId")); } // 3. 查询当前合同状态(关键校验!) Contract contract = DbUtil.findById(contractId, Contract.class); if (contract == null) { sendError(resp, "合同不存在"); return; } // 4. 状态合法性校验(例如:只有"待审批"状态才能被审批) String action = uri.contains("approve") ? "approve" : "reject"; if ("approve".equals(action) && contract.getStatus() != 1) { sendError(resp, "合同当前状态为[" + getStatusName(contract.getStatus()) + "],无法执行审批通过操作"); return; } if ("reject".equals(action) && contract.getStatus() != 1) { sendError(resp, "合同当前状态为[" + getStatusName(contract.getStatus()) + "],无法执行驳回操作"); return; } // 5. 权限校验:只有合同所属部门负责人能审批(简化版:查 t_user 表 dept_id 匹配) User operator = DbUtil.findById(user.getId(), User.class); if (operator.getDeptId() != contract.getDeptId()) { sendError(resp, "您不属于该合同所属部门,无权审批"); return; } // 校验通过,放行 chain.doFilter(request, response); } private void sendError(HttpServletResponse resp, String msg) throws IOException { resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().write("<script>alert('" + msg + "');history.back();</script>"); } }

web.xml 中注册 Filter:

<filter> <filter-name>ContractOperationFilter</filter-name> <filter-class>com.example.contract.filter.ContractOperationFilter</filter-class> </filter> <filter-mapping> <filter-name>ContractOperationFilter</filter-name> <url-pattern>/contract/approve</url-pattern> <url-pattern>/contract/reject</url-pattern> </filter-mapping>

注意:此 Filter 仅做「准入校验」,不执行状态更新。真正的状态变更由ApproveServlet完成,确保职责分离——Filter 负责「能不能做」,Servlet 负责「怎么做」。

3.3 ApproveServlet:原子化更新状态 + 记录操作日志

状态更新必须是数据库事务,且要同时更新t_contract主表和写入操作日志。我们用DbUtil.tx()封装事务:

// java/com/example/contract/servlet/ApproveServlet.java protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Long contractId = Long.parseLong(req.getParameter("contractId")); String action = req.getParameter("action"); // "approve" or "reject" String reason = req.getParameter("reason"); // 驳回理由,approve 时可为空 DbUtil.tx(conn -> { // 1. 更新合同状态 String updateSql = "UPDATE t_contract SET status = ?, last_operator_id = ?, " + "last_operation_time = NOW(), last_operation_desc = ? WHERE id = ?"; int newStatus = "approve".equals(action) ? 2 : 0; // 批准→已签署,驳回→草稿 String desc = "approve".equals(action) ? "审批通过" : "驳回:" + reason; DbUtil.update(conn, updateSql, newStatus, ((User)req.getSession().getAttribute("user")).getId(), desc, contractId); // 2. 写入操作日志(独立日志表,便于审计) String logSql = "INSERT INTO t_operation_log (contract_id, operator_id, operation_type, " + "operation_desc, create_time) VALUES (?, ?, ?, ?, NOW())"; DbUtil.update(conn, logSql, contractId, ((User)req.getSession().getAttribute("user")).getId(), action, desc); // 3. 如果是批准,还需处理签字页上传(此处简化为检查 request 中是否有文件) if ("approve".equals(action)) { Part filePart = req.getPart("signFile"); if (filePart != null && filePart.getSize() > 0) { // 保存文件到 uploads/ht/sign/ 目录,并插入 t_contract_attachment String fileName = filePart.getSubmittedFileName(); String filePath = "/uploads/ht/sign/" + contractId + "_" + fileName; filePart.write(getServletContext().getRealPath(filePath)); String insertAttSql = "INSERT INTO t_contract_attachment " + "(contract_id, file_name, file_path, file_size, file_type, uploader_id) " + "VALUES (?, ?, ?, ?, ?, ?)"; DbUtil.update(conn, insertAttSql, contractId, fileName, filePath, (int)filePart.getSize(), filePart.getContentType(), ((User)req.getSession().getAttribute("user")).getId()); } } }); // 重定向回列表页,带 success 参数 resp.sendRedirect(req.getContextPath() + "/contract/list.jsp?msg=success"); }

参数说明:

  • DbUtil.tx()是自定义的事务模板方法,内部调用conn.setAutoCommit(false),成功则commit(),异常则rollback();
  • t_operation_log表需提前建好,包含id,contract_id,operator_id,operation_type(varchar)、operation_desc(text)、create_time字段;
  • 签字页上传使用 Servlet 3.0+ 的request.getPart(),比 Apache Commons FileUpload 更轻量,无需额外依赖;
  • 重定向而非转发,避免用户刷新导致重复提交(这是 Web 表单的黄金法则)。

4. 合同 PDF 生成与电子签章:用 iText 7 + 图片水印实现合规输出

合同一旦签署,就必须能生成具有法律效力的 PDF 文件。很多项目用JSP + CSS print导出,结果字体乱码、页眉页脚错位、无法添加防伪水印。我们必须用专业 PDF 库——iText 7(注意:iText 5 已停止维护,iText 7 是当前标准)。

4.1 引入 iText 7 依赖与基础 PDF 生成

在pom.xml中添加:

<dependency> <groupId>com.itextpdf</groupId> <artifactId>itext7-core</artifactId> <version>7.2.5</version> <type>pom</type> <scope>import</scope> </dependency>

提示:iText 7 分多个模块,itext7-core是父 POM,会拉取kernel,layout,pdfa,sign等核心子模块。不要单独引入itextpdf(那是 iText 5 的 groupId)。

生成合同 PDF 的核心逻辑在PdfGenerator工具类中:

// java/com/example/contract/util/PdfGenerator.java public class PdfGenerator { public static void generateContractPdf(Long contractId, OutputStream out) throws Exception { // 1. 查询合同及关联数据 Contract contract = DbUtil.findById(contractId, Contract.class); List<ContractParty> parties = DbUtil.query( "SELECT * FROM t_contract_party WHERE contract_id = ?", ContractParty.class, contractId); List<ContractAttachment> attachments = DbUtil.query( "SELECT * FROM t_contract_attachment WHERE contract_id = ? AND file_type = 'application/pdf'", ContractAttachment.class, contractId); // 2. 创建 PDF writer PdfWriter writer = new PdfWriter(out); PdfDocument pdfDoc = new PdfDocument(writer); Document document = new Document(pdfDoc); // 3. 设置中文字体(关键!否则中文乱码) PdfFont font = PdfFontFactory.createFont("simhei.ttf", PdfEncodings.IDENTITY_H, true); document.setFont(font).setFontSize(12f); // 4. 添加合同标题 document.add(new Paragraph("合同文本") .setFont(font).setFontSize(16f).setBold() .setTextAlignment(TextAlignment.CENTER) .setMarginBottom(20f)); // 5. 添加合同基本信息表格 Table table = new Table(UnitValue.createPercentArray(new float[]{1, 2})) .useAllAvailableWidth(); table.addCell(new Cell().add(new Paragraph("合同编号:"))); table.addCell(new Cell().add(new Paragraph(contract.getContractNo()))); table.addCell(new Cell().add(new Paragraph("合同标题:"))); table.addCell(new Cell().add(new Paragraph(contract.getTitle()))); table.addCell(new Cell().add(new Paragraph("签订日期:"))); table.addCell(new Cell().add(new Paragraph(DateUtils.formatDate(contract.getSignDate())))); document.add(table); // 6. 添加甲方乙方信息(循环 parties) for (ContractParty party : parties) { String role = party.getPartyType() == 1 ? "甲方" : "乙方"; document.add(new Paragraph(role + "信息:").setBold()); document.add(new Paragraph("单位名称:" + party.getOrgName())); document.add(new Paragraph("法定代表人:" + party.getLegalRep())); document.add(new Paragraph("地址:" + party.getAddress())); document.add(new Paragraph("联系方式:" + party.getPhone())); } // 7. 添加合同正文(此处简化为从数据库读取富文本字段,实际项目应存 HTML 或 Markdown) document.add(new AreaBreak()); // 分页符 document.add(new Paragraph("第一条 合同内容").setBold()); document.add(new Paragraph(contract.getContent())); // 假设 Contract 表有 content TEXT 字段 // 8. 添加签字页(图片水印 + 空白区域) addSignaturePage(document, contractId); document.close(); } private static void addSignaturePage(Document document, Long contractId) { // 插入签字页图片(预置好的签字页模板,含公司LOGO、签字栏、日期栏) try { ImageData imageData = ImageDataFactory.create( PdfGenerator.class.getResource("/static/img/sign_page_template.png")); Image image = new Image(imageData); image.scaleToFit(500, 700); document.add(image); // 在签字页添加半透明防伪水印("合同专用" + 时间戳) PdfPage page = document.getPdfDocument().getLastPage(); PdfCanvas canvas = new PdfCanvas(page); canvas.saveState(); canvas.setFillColor(ColorConstants.LIGHT_GRAY); canvas.beginText(); canvas.setFontAndSize(PdfFontFactory.createFont(), 60); canvas.setTextMatrix(1, 0, 0, 1, 200, 300); canvas.showText("合同专用"); canvas.endText(); canvas.restoreState(); } catch (Exception e) { throw new RuntimeException("签字页生成失败", e); } } }

关键参数说明:

  • simhei.ttf是 Windows 自带黑体,需放在src/main/resources/static/fonts/目录下,并确保打包后路径正确;生产环境建议用 Noto Sans CJK SC(开源免费)替代;
  • AreaBreak()是 iText 7 的分页符,比document.add(new AreaBreak())更准确,避免内容被截断;
  • PdfCanvas直接操作底层 PDF 绘图,用于添加水印、页眉页脚等高级效果,比document.add()更底层、更灵活;
  • 签字页使用「预置图片模板」而非动态绘制,是因为手写签名、公司印章、骑缝章等元素涉及复杂图形和法律效力,用图片最稳妥。

4.2 电子签章的合规性落地:不碰私钥,只做可视化呈现

严格来说,JavaWeb 项目不应在应用层生成数字签名(那需要接触用户私钥,违反《电子签名法》关于私钥保管的要求)。真实合规的做法是:系统只生成带占位符的 PDF,由第三方 CA 平台(如 e 签宝、法大大)调用其 API 注入合法数字签名。

但我们可以在 PDF 中预留签名位置,并生成「可视化电子签章」供内部审阅:

// 在 addSignaturePage() 方法末尾追加 private static void addVisualSeal(Document document, String sealText) { PdfPage page = document.getPdfDocument().getLastPage(); PdfCanvas canvas = new PdfCanvas(page); // 绘制红色印章边框 canvas.setLineWidth(2f); canvas.setStrokeColor(ColorConstants.RED); canvas.circle(400, 150, 80); // 圆心 x=400, y=150, 半径 80 canvas.stroke(); // 绘制印章内文字(旋转排布) canvas.saveState(); canvas.setFillColor(ColorConstants.RED); canvas.beginText(); canvas.setFontAndSize(PdfFontFactory.createFont(), 10); // 上弧文字:"合同专用章" drawArcText(canvas, "合同专用章", 400, 150, 70, 0, 180); // 下弧文字:当前日期 String now = DateUtils.formatDate(new Date(), "yyyy年MM月dd日"); drawArcText(canvas, now, 400, 150, 70, 180, 360); canvas.endText(); canvas.restoreState(); } private static void drawArcText(PdfCanvas canvas, String text, float cx, float cy, float radius, float startAngle, float endAngle) { // 此处省略具体弧形文字绘制逻辑(需三角函数计算每个字符坐标) // 实际项目可复用开源库如 itext7-samples 中的 ArcTextRenderer }

注意:这个「红色印章」只是视觉示意,不具备法律效力。它存在的意义是:让业务人员在 PDF 预览时清晰看到「此处应有电子签章」,避免误以为合同未签署。真正的数字签名,必须由持牌 CA 平台注入。


5. 避坑指南:合同管理系统开发中 5 个高频翻车点与血泪解决方案

做合同系统,90% 的时间花在填坑上。以下是我带过 7 个外包项目、交付 12 家企业客户后,总结出的最痛、最隐蔽、最容易被忽略的 5 个坑。它们不写在任何教材里,但每一个都曾让我加班到凌晨三点。

5.1 现象:合同编号生成重复,两个用户同时点击「新建」得到相同编号 HT-2024-001

原因:用SELECT MAX(id) FROM t_contract算序号,高并发下两条 SELECT 几乎同时返回 0,都算出HT-2024-001。
解决:放弃「查最大值+1」,改用数据库自增 ID 做种子,再格式化。在ContractService.add()中:

// 先插入一条空记录获取自增ID long tempId = DbUtil.insert("INSERT INTO t_contract (title) VALUES ('temp')", new Object[]{}); String contractNo = String.format("HT-%d-%03d", Calendar.getInstance().get(Calendar.YEAR), tempId % 1000); // 用 tempId 作为序号,绝对不重复 // 再用 contractNo 更新这条记录 DbUtil.update("UPDATE t_contract SET contract_no = ?, title = ? WHERE id = ?", contractNo, title, tempId);

玄学经验:tempId % 1000是为了保证每年序号从 001 开始,且不会超过 999。如果真要突破 999,就用tempId % 10000并调整格式化字符串。

5.2 现象:JSP 页面中文乱码,Tomcat 8 下request.getParameter("title")返回????

原因:Tomcat 默认用 ISO-8859-1 解码 GET 请求,而浏览器发送的是 UTF-8。POST 请求虽可通过request.setCharacterEncoding("UTF-8")解决,但 GET 请求必须改 Tomcat 配置。
解决:编辑conf/server.xml,在<Connector>标签中添加URIEncoding="UTF-8":

<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" />

血泪经验:这个配置必须加在server.xml,不能只在 Servlet 里new String(param.getBytes("ISO-8859-1"), "UTF-8")—— 那是补丁,不是根治。

5.3 现象:合同附件上传失败,request.getPart("file")返回 null

原因:JSP 表单未设置enctype="multipart/form-data",或web.xml中未声明 Servlet 3.0+ 版本。
解决:两步缺一不可:

  1. JSP 表单:<form method="post" enctype="multipart/form-data">;
  2. web.xml头部声明:<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0">。

提示:IDEA 新建项目时默认是 2.5 版本,必须手动升级,否则getPart()方法根本不存在。

5.4 现象:合同状态更新后,列表页缓存旧数据,F5 刷新才显示新状态

原因:浏览器对 GET 请求做了强缓存,尤其是带参数的/contract/list.jsp?status=2。
解决:在ContractListServlet的doGet开头强制禁用缓存:

resp.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); resp.setHeader("Pragma", "no-cache"); resp.setDateHeader("Expires", 0);

翻车现场:曾有个客户投诉「系统不更新」,排查 2 小时才发现是 Chrome 缓存问题,加这三行代码,问题消失。

5.5 现象:PDF 导出

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 8:47:46

STM32H750嵌入式GUI中高效加载PNG图像的完整方案

简介&#xff1a;本资源是一份面向嵌入式开发工程师与STM32进阶学习者的GUI实战项目&#xff0c;聚焦STM32H750平台下EMWIN图形库对PNG图像的完整支持方案&#xff0c;解决高性能Cortex-M7芯片上资源受限场景下的无损图像解码与高效显示难题。压缩包共547个文件&#xff0c;以2…

作者头像 李华
网站建设 2026/11/3 8:47:41

JavaScript自定义树形控件开发实践

1. 项目概述 今天要分享的是一个基于JavaScript的自定义树形控件实现方案。这个tree.js组件是我在最近一个后台管理系统项目中开发的&#xff0c;主要用于展示层级化的数据结构和实现交互操作。相比市面上现成的树形控件&#xff0c;这个自定义方案更加轻量灵活&#xff0c;能够…

作者头像 李华
网站建设 2026/11/3 8:47:20

剧本杀小程序前端设计与实时交互技术实践

1. 剧本杀组车小程序的前端设计理念 作为一个深度参与过多个社交类小程序开发的工程师&#xff0c;我深知剧本杀组车这类垂直领域产品的核心痛点。线下组局最让人头疼的就是"三难"问题&#xff1a;找人难、协调难、沟通难。我们团队在设计这款小程序时&#xff0c;始…

作者头像 李华
网站建设 2026/11/3 8:47:05

ArcGIS Pro空间符号化实战:从视觉表达到地理语义传递

1. 项目概述&#xff1a;为什么空间数据符号化不是“换个颜色那么简单”在GIS实际工作中&#xff0c;我见过太多人把ArcGIS符号化当成PPT配色操作——点开图层属性&#xff0c;调个红蓝黄绿&#xff0c;导出一张“看起来像地图”的图就交差。结果呢&#xff1f;某次给某高校地理…

作者头像 李华
网站建设 2026/11/3 8:46:11

航天器巨吨位生产与太空部署技术创新解析

1. 项目背景与核心概念解析 在航天科技领域&#xff0c;我们正面临一个前所未有的技术突破窗口期。传统航天器设计受限于材料科学、制造工艺和空间环境适应性等因素&#xff0c;长期停留在"吨位受限、功能单一"的发展阶段。而本项目提出的系列创新技术&#xff0c;将…

作者头像 李华
网站建设 2026/11/3 8:42:49

MiMo-V2.6开源模型深度解析:Flash Attention工程化与轻量化部署实践

1. 项目概述&#xff1a;这不是一次普通升级&#xff0c;而是一次开源模型领域的“静默突围”小米 MiMo-V2.6 的发布&#xff0c;在技术圈里没有铺天盖地的发布会、没有KOL轮番解读、甚至没上微博热搜前三——但它实实在在地改写了当前中文开源大模型的格局。我盯着 AA 指数&am…

作者头像 李华