简介面向 Java 学习者与毕业设计开发者的 SSM 企业合同管理系统资源包基于 SSM 框架搭建覆盖员工注册、合同模板维护、新签/变更/借阅审批、法务-经理-用印管理员多级审核及按年份归档等完整业务闭环。系统分为普通员工、管理级员工与系统管理员三类角色后台可对员工账号、权限、模板与归档合同进行管理前台支持登录改密、模板检索下载、审批申请与打印导出适合课程设计及毕设二次开发。压缩包约 28.22MB以演示与源码工程为主包内演示可直观看到合同从拟稿、审批、审核到归档的完整流程源码则便于对照分析 SSM 的分层实现、权限拦截、文件上传下载等关键代码。已有 76 人学习下载对希望快速掌握 SSM 实际项目应用的开发者而言是一份可直接运行的参考范例。1. 为什么一个合同管理系统卡住了那么多 SSM 项目做企业级 SSM 项目时最难的不是 CRUD而是把「审批流」和「状态机」揉进一张表里还不乱。这个基于 SSM 的企业合同管理系统主题看着是合同管理实际拆开全是权限设计、多级审批状态流转、归档编号生成的细节。这类题目在毕设和企业内部系统中出现频率极高原因在于它覆盖了 SSM 框架里最有含金量的几个点拦截器做权限控制、MyBatis 多条件动态查询、状态字段驱动业务流程、文件上传下载。它的角色设计非常贴近真实企业普通员工只能拟稿和提交审批法务、经理、用印管理员必须依次审核系统管理员负责员工注册和模板维护。整个审批状态分为「未查看、正在处理、已处理未准、已准」这背后其实是一个「多角色联合审批」的状态机。适合正在做 SSM 课设、或者想搞明白企业审批流如何用最简单方式落地的人。本文不贴完整源码但会把拆包后最该看懂的表结构、状态流转逻辑、档案编号生成规则和部署要点写透照着能复现核心功能。2. 从角色权限到 SSM 三层表结构先想清楚数据怎么落2.1 角色权限模型不是 RBAC而是「岗位 功能码」这个系统没有走复杂的 RBAC 五表模型而是用「用户表 角色字段 功能码校验」的方式实现。员工表里直接存permission_level字段值域是普通员工、法务、经理、用印管理员、系统管理员。代码里通过拦截器比对功能码来决定能不能访问某个 Controller这种方式在中小型系统里比完整 RBAC 更实用SQL 查询简单后端校验也不绕。// 权限级别定义对应数据库 permission_level 字段 public enum RoleLevel { EMPLOYEE(1, 普通员工), LEGAL(2, 法务), MANAGER(3, 经理), SEAL_ADMIN(4, 用印管理员), ADMIN(5, 系统管理员); }这段枚举的意义在于审批流程中「法务 → 经理 → 用印管理员」的依次审核本质上是枚举值的升序。审核表里记录当前审批到了哪个角色每次通过就把current_level加一直到达到SEAL_ADMIN层级整个流程才算结束。这种设计省掉了一张独立的流程定义表但代价是流程不可扩展新增审核角色必须改枚举和校验逻辑。2.2 六张核心表合同主表怎么和审批流关联拆完源码里的 SQL 脚本核心表设计非常典型。用户表t_employee之外t_contract_template存模板、t_approval存审批主表、t_contract存归档后的正式合同、t_approval_comment存审核评论。注意这里t_approval和t_contract是一对一关系审批通过后才会生成合同归档记录归档记录里回填审批编号。CREATE TABLE t_approval ( approval_id varchar(32) NOT NULL COMMENT 审批编号唯一, dept varchar(50) DEFAULT NULL COMMENT 部门, handler varchar(50) DEFAULT NULL COMMENT 经办人, approval_date date DEFAULT NULL COMMENT 提交日期, file_name varchar(200) DEFAULT NULL COMMENT 文件名称, contract_type varchar(20) DEFAULT NULL COMMENT 合同类型业务/租赁/其他, file_count int DEFAULT NULL COMMENT 文件份数, seal_type varchar(50) DEFAULT NULL COMMENT 加盖印章类型, remark varchar(500) DEFAULT NULL, current_level int DEFAULT 1 COMMENT 当前审批层级1法务 2经理 3用印, status varchar(10) DEFAULT UNVIEWED COMMENT UNVIEWED/PROCESSING/DONE, result varchar(10) DEFAULT NULL COMMENT PASS/REJECT, PRIMARY KEY (approval_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里最关键的是current_level和status两个字段的组合逻辑。current_level表示流程走到了哪一级审核人status表示整条审批对外展示的状态。一个常见错误是只更新status不更新current_level导致流程界面显示「已处理」但法务实际没审核过。在这个项目里三个角色全部审核通过后status才会变为DONEresult标记最终是准还是不准。2.3 MyBatis 多条件检索所有查询页面共用一套动态 SQL系统里审批列表、归档列表、模板列表都支持按多条件搜索。拆包后发现 Mapper 层用了大量的if动态 SQL 拼条件这个写法虽然基础但对 SSM 项目来说最直观。以审批列表的检索为例select idselectApprovalByCondition resultTypeApproval SELECT * FROM t_approval where if testapprovalId ! null and approvalId ! AND approval_id #{approvalId} /if if testdept ! null and dept ! AND dept LIKE CONCAT(%, #{dept}, %) /if if testhandler ! null and handler ! AND handler #{handler} /if if testcontractType ! null and contractType ! AND contract_type #{contractType} /if if teststatus ! null and status ! AND status #{status} /if /where ORDER BY approval_date DESC /select2.3.1 动态 SQL 的参数绑定细节where标签会自动去掉第一个多余 AND这是 MyBatis 最常用的技巧。排序固定按approval_date倒序保证新提交的审批排在最前面。注意合同类型、状态这类字段用等值匹配而部门、文件名称用 LIKE 模糊匹配这是从查询效率角度做的合理区分。实际改这个系统时如果发现检索变慢优先看这些字段有没有建索引尤其是approval_id和status。3. 审批状态机未查看、正在处理、已处理背后的流转逻辑3.1 三层审核顺序怎么用代码保证法务、经理、用印管理员依次审核这个「依次」是硬规则不能用「三个人都审核过就行」这种松散逻辑实现。源码中ApprovalServiceImpl里有一段状态流转方法每次审核时先比对当前用户的角色层级和审批记录里的current_level是否一致不一致直接抛出异常。只有当前层级审核通过才会把current_level1否则流程就停在原地。Transactional public void approve(String approvalId, String roleLevel, boolean pass, String comment) { Approval approval approvalMapper.selectById(approvalId); // 校验当前操作人是否是流程中应审核的角色 if (approval.getCurrentLevel() ! Integer.parseInt(roleLevel)) { throw new BizException(当前不是你的审核节点); } if (pass) { if (approval.getCurrentLevel() 3) { approval.setCurrentLevel(approval.getCurrentLevel() 1); approval.setStatus(PROCESSING); } else { approval.setCurrentLevel(3); approval.setStatus(DONE); approval.setResult(PASS); } } else { approval.setStatus(DONE); approval.setResult(REJECT); } approvalMapper.updateById(approval); // 插入审核评论 approvalCommentMapper.insert(new ApprovalComment(approvalId, roleLevel, comment)); }3.1.1 这段代码的三个关键决策Transactional保证状态更新和评论插入要么全成功要么全失败这是审批系统绝对不能省的事务控制。current_level从 1 到 3 递增只有最后一个角色用印管理员通过后才把status置为DONE这个判断逻辑避免了「已处理但 result 为空」的中间态。驳回操作直接把整条流程终止而不是退回上一级重新走这是项目本身的设定好处是逻辑简单坏处是真实场景中驳回应允许修改后重新提交这个可以在二次开发时扩充一张驳回记录表。3.2 前端三个状态列表怎么数据驱动渲染审批页面前端有三个 Tab未查看、正在处理、已处理。这个不是前端自己切的是后端返回数据时按status字段区分。普通员工登录后列表接口查出自己提交的所有审批根据status返回给前端三个数组。管理级员工看到的则是待自己审核的所有审批。状态值展示名称触发条件前端操作UNVIEWED未查看当前审批处于第 1 层三个审核角色均未操作显示「审核」按钮PROCESSING正在处理current_level为 2 或 3部分角色已审核显示「审核」按钮和已审核记录DONE已处理current_level为 3 且result有值显示「查看详情」按钮前端列表渲染时同一个接口的数据按状态分组展示。这个设计比前端反复调三个接口要省事一次查询全部数据前端用 JavaScript 进行过滤。真正动手改这个系统时如果想增加「待我审核」的角标提醒只要在后端 Service 里加一个countByCurrentLevelAndRole方法统计current_level等于当前用户角色层级且statusPROCESSING的记录数。3.3 审核评论与附件一对多表结构管理级员工在审核时可以发表评论、上传附件。源码里附件不是存二进制大字段而是存文件路径服务器上建一个 upload 目录通过 UUID 重命名文件避免覆盖。评论表结构单独一张t_approval_comment外键关联审批编号。这种方式在 SMM 项目里最稳妥文件存在磁盘上数据库只存路径备份时只要把 upload 目录一起拷走就行。CREATE TABLE t_approval_comment ( id int PRIMARY KEY AUTO_INCREMENT, approval_id varchar(32) NOT NULL, role_level int NOT NULL COMMENT 评论人角色层级, comment varchar(500) DEFAULT NULL, attachment_path varchar(200) DEFAULT NULL COMMENT 附件相对路径, create_time datetime DEFAULT CURRENT_TIMESTAMP );3.3.1 上传文件的 SpringMVC 配置要点SpringMVC 处理文件上传需要在spring-mvc.xml里配置CommonsMultipartResolver并且要注意maxUploadSize参数限制。这个项目里设置的是 10MB超过会抛异常进入全局异常处理器不会让用户看到一堆英文错误堆栈。附件路径存相对路径而不是绝对路径这样项目迁移到其他服务器不会因为盘符不同导致文件访问失败。bean idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namedefaultEncoding valueUTF-8/ property namemaxUploadSize value10485760/ /bean4. 合同归档档案编号规则与年份分组的实现方法4.1 档案编号的生成算法19 年从 190000001 开始合同审核通过后进入归档环节。系统要求 2019 年归档合同编号从 190000001 开始2020 年从 200000001 开始每归档一份 1。这个规则在源码里实现方式并不复杂核心是「同年份查最大值加一」public String generateArchiveNo(String year) { // 查询当前年份已有的最大归档编号 String prefix year.substring(2); // 2019 - 192020 - 20 String maxNo contractMapper.selectMaxArchiveNoByYear(prefix %); if (maxNo null) { return prefix 0000001; } long next Long.parseLong(maxNo) 1; return String.valueOf(next); }4.1.1 为什么这里不用数据库自增主键如果用数据库自增 ID 做编号当年份切换时无法自动重置——2020 年不可能从 200000001 开始如果 2019 年已经自增到 190001000 的话。按年份前缀查最大值再 1 的方式天然支撑了「每年重新编号」的规则。要注意的是并发问题两个人同时归档时可能查出同一个最大值导致编号重复。课设场景可以忽略但真实企业环境建议在归档表加唯一索引uk_archive_no数据库层面兜底重复时抛异常重试一次。归档列表要求先按年份分组展示再按编号排序。这个用 GROUP BY 并不合适因为你的数据本体是合同记录而不是统计数量。实际做法是先查出所有归档记录Java 里按年份字段分组每组内部再按档案编号升序排前端展示年份作为分组标题。public MapString, ListContract groupByYear() { ListContract list contractMapper.selectAllArchived(); MapString, ListContract result new TreeMap(Collections.reverseOrder()); // 年份倒序 for (Contract c : list) { String year c.getSignDate().substring(0, 4); // 取签约日期年份 result.computeIfAbsent(year, k - new ArrayList()).add(c); } return result; }4.2 归档字段中的常见坑签约日期和截止日期归档表单字段非常多合同类型、签约日期、截止日期、审批编号、合同编号、合同标题、甲方名称、门店名称、省份、城市、原件签约人、合同移交人、归档份数、档案编号、附件、备注。其中最容易出问题的是前端传入的日期字符串格式。这个项目用 jQuery 的 datepicker 插件默认输出格式是yyyy-MM-dd但有时浏览器时区差异会导致传yyyy/MM/dd。建议在 Controller 接收参数时统一用DateTimeFormat(pattern yyyy-MM-dd)注解指定格式避免数据库报Incorrect date value错误。合同归档页面的检索支持按合作方名称、合同编号、审批编号、档案编号四种维度查这四种都是等值或模糊查询MyBatis 里分别写四个if判断即可。4.3 合同模板下载文件路径的权限控制模板管理区分前后台权限。普通员工能检索、查看、使用、下载模板管理级员工和系统管理员还可以上传、删除、修改模板。下载功能要特别注意权限校验不能只在前端隐藏按钮后端 Controller 每个方法上都要加拦截器校验。这个项目用自定义AuthInterceptor拦截所有/template/*和/approval/*请求在preHandle里从 Session 取出当前用户角色匹配不上直接重定向到登录页。public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Employee emp (Employee) session.getAttribute(loginUser); if (emp null) { response.sendRedirect(request.getContextPath() /login.jsp); return false; } String uri request.getRequestURI(); if (uri.contains(/admin/) emp.getPermissionLevel() 2) { response.setStatus(403); return false; } return true; }4.3.1 拦截器注册和放行名单SpringMVC 里需要注册这个拦截器并配置放行路径否则静态资源和登录接口会被拦截。登录页面、登录接口、静态资源文件JS、CSS、图片必须放行。这个项目里合理配置是拦截/admin/*、/template/*、/approval/*放行/login和/static/**。一个常见错误是拦截器把/static/bootstrap/js/bootstrap.min.js也拦了导致页面样式全丢排查时看浏览器控制台的 302 跳转就能定位。5. 系统管理员的员工注册与密码加密SSM 里的基础安全实践5.1 员工注册手机号作为登录账号的设计考量系统管理员注册新员工时字段包括员工号、姓名、性别、电话、邮箱、所属区域、部门、账号、初始密码、权限级别。这里账号字段是手机号那么关键问题是登录时到底输入手机号还是账号源码里实际逻辑是「电话」字段和「账号」字段是同一个值注册界面把手机号同时写入两个字段账号字段直接取电话输入框的值。PostMapping(/register) public String register(Employee emp) { // 账号默认为手机号初始密码 123456强制首次登录修改 emp.setAccount(emp.getPhone()); emp.setPassword(DigestUtils.md5DigestAsHex(123456.getBytes())); emp.setPermissionLevel(1); // 默认普通员工 employeeMapper.insert(emp); return redirect:/admin/employeeList; }5.1.1 初始密码策略与 MD5 加密的取舍初始密码统一为123456的逻辑不复杂重点在于「首次登录强制修改密码」这个设计在源码里是前端判断还是后端判断拆包发现是在拦截器中判断如果 Session 中用户密码仍是初始密码且当前密码修改接口未被调用过就重定向到修改密码页。这个方案后端的成本极低且无法被绕过——只要不进updatePassword接口任何请求都会被拦截。MD5 加密在真实系统中不够安全但课设和内部系统足够。如果想快速加固把DigestUtils.md5DigestAsHex换成 BCrypt 编码Spring Security 里有现成的BCryptPasswordEncoder类改动只涉及注册和登录两处密码格式变化不影响表结构。5.2 修改密码与头像上传的事务处理修改密码接口需要处理一个边界情况员工修改密码成功后Session 中的用户对象还存着旧密码如果不更新 Session同一个会话里登录态判断会出问题。源码在 Service 里更新完数据库后返回更新后的员工对象Controller 里重新session.setAttribute(loginUser, updatedEmp)。这一步漏掉的话会出现奇特的 Bug数据库密码已更新但当前会话不撞库校验而是对比 Session 里的旧密码导致新密码无法登录。头像上传的逻辑和合同附件一致也是上传到磁盘目录、数据库存路径。注意上传头像时做两个操作删除旧头像文件如果存在的话和写入新文件这两个操作不能保证原子性——删除成功后写入失败的话用户头像会丢失。稳妥的做法是先写新文件、再删旧文件写新文件成功了再操作数据库即使在删除旧文件时失败最多是磁盘上有残留文件不影响用户看到新头像。6. 从源码到可运行SSM 项目部署的七个关键配置与常见报错拿到这个项目源码后从导入 IDEA 到跑起来最常卡住的不是代码而是环境配置。这里把直接能用的操作顺序和三个高频报错写清楚。首先确认 JDK 版本SSM 项目基本是 JDK 8不要用 17否则 Tomcat 版本和编译选项都会出问题。Maven 仓库的settings.xml建议先换成阿里云镜像不然拉依赖要等很久。配置顺序上先改jdbc.properties里的数据库连接把用户名密码换成你本地的再执行 SQL 脚本建表。注意字符编码characterEncodingutf-8一定要加上否则中文乱码。接着确认 Tomcat 版本这个项目用 Tomcat 8.5 最稳Tomcat 9 也兼容但 Tomcat 10 的 Jakarta 命名空间改动会导致包名全部报错。最后启动时如果端口占用在server.xml里改三个端口号HTTP 端口和 redirect 端口要一起改。第一个高频报错是Invalid bound statement (not found)原因是 MyBatis 的 Mapper 接口和 XML 文件不在同一个包路径下或者 XML 文件没被 Maven 编译进 classes 目录。检查pom.xml里的 resources 配置确保把mapper/*.xml包含进构建路径。第二个报错是数据库密码没改Spring 启动时建立连接失败看日志里的 Access denied 提示。第三个是commons-fileupload和commons-io版本不匹配上传文件时抛FileUploadException把两个依赖都升到 1.4 以上能解决。验证java -jar启动后还能否正常访问页面是最后一步。由于项目是 war 包部署在外部 Tomcat不是 Spring Boot 内嵌容器改完代码直接重启 Tomcat 可能会出现 class 加载冲突。检查WEB-INF/lib下是否有多个版本的 spring-webmvc、mybatis-spring 等 jar 包用 Maven 的dependency:tree命令检查依赖树里的版本冲突有冲突就全局排除低版本。日志里出现ClassNotFoundException时优先查这个不用急着怀疑代码问题。本文还有配套的精品资源点击获取