
简介面向Java Web与小程序方向学习者这是一套基于SSM框架的企业合同管理系统完整项目覆盖合同拟稿、审批、审核、归档全流程适合课程设计、毕业设计或企业信息化入门实践。系统规划了普通员工、法务、经理、用印管理员、系统管理员等角色前后台权限分明业务闭环完整。压缩包约28.22MB已吸引76人学习下载可用于快速搭建合同管理原型。配套演示与源码可帮助理解SSM整合、角色权限控制、审批流设计等关键环节员工注册、合同模板分类业务类、租赁类、其他类、审批状态流转、合同归档编号按年份自动生成等模块都有可运行的实现适合边读代码边对照界面梳理业务流程二次开发上手也较为直接。对于需要完成JavaWeb大作业或想了解企业合同审批数字化实现的学习者这是一份值得拆解的实战样例。1. 为什么大多数 SSM 企业合同管理系统课设死在了表结构设计上标题给的是演示源码很多人拿到手的第一反应是把代码导入 IDEA按下启动键然后停下来等 Tomcat 起来。实际带过毕业设计和做过企业内部系统的工程师都知道一个反直觉的结论这类基于 SSM 的企业合同管理系统跑不起来的原因只有三成是框架版本冲突剩下七成都藏在表结构和状态设计里。合同不是只有增删改查两张页面的学生信息管理系统它有金额、有税率、有审批、有甲方乙方还要能回答这份合同现在在哪个环节。下面直接按这套技术栈讲一个能演示、也能写进答辩 PPT 的做法先画合同状态机再用 Spring 管事务、Spring MVC 接参数、MyBatis 做动态查询最后落到演示环境怎么一次跑通、上线前验证哪些点。适合正在组 SSM 大作业源码包的人也适合刚接手老合同系统的工程师。2. 基于 SSM 的企业合同管理系统先做业务建模再谈框架选型2.1 合同生命周期建模先画出状态再决定数据表字段合同管理系统和其他 CRUD 课设最不一样的地方是每一份合同都会经历草稿、待审批、已生效、履行中、已到期、已归档这个状态流转。连审批都没有的合同系统演示时只能展示列表和新增页答辩时对面一句合同为什么没有审批流就会卡住。所以第一件事不是建项目而是画状态机再让表字段去承接状态。常见做法是给 contract 表加两个字段status 记录当前状态version 做乐观锁再单独建 contract_log 表记录每次流转的操作用户和动作。不要把历史状态拼在业务表里比如用 remark 记作废原因、变更说明查询和统计都会变得很难受。如果这份源码里有合同变更、续签功能那额外考虑 parent_id 指向原合同没有这些功能就不用为以后扩展提前建一堆空表。表结构里的最小字段集大致如下这套字段可以直接挪到 Navicat 里建表字段类型说明idint/bigint主键自增contract_novarchar(64)唯一合同编号演示可用 HT日期序列contract_namevarchar(255)合同名称列表页显示party_a / party_bvarchar(255)甲方 / 乙方全称amountdecimal(18,2)合同金额tax_ratedecimal(5,2)税率企业合同演示加分项statusvarchar(16)DRAFT / APPROVING / EFFECTIVE / FINISHED / EXPIREDsign_date / effective_date / expire_datedate签约、生效、到期三个日期分开存create_by / create_timevarchar / datetime创建留痕在状态问题上最容易让演示翻车的是把 status 用 int 存成 0/1/2代码里到处是魔法数字。虽然能跑但你在 Service 里写if(0contract.getStatus())时三个月后自己都反应不过来 0 是草稿还是生效。建议一开始就定义枚举常量类或者至少用字符串常量把 DRAFT、EFFECTIVE 写清楚报表和权限判断会省很多事。2.2 SSM 三层架构在企业合同系统里的真正边界SSM 由 Spring、Spring MVC、MyBatis 三个框架组成网课笔记里最常出现的分层图是 Controller - Service - Mapper但真正写合同系统时这条链上最含糊的是三层各管什么、绝对不能跨层做什么。我的划分经验是三条硬规则第一跨表写操作必须在 Service 里编排Controller 只能调用一个业务方法第二Mapper 里只放单表或明确圈定范围的 SQL像合同状态统计这种聚合查询可以放到专门的统计 Mapper第三Controller 里不允许出现 SqlSession、SqlSessionTemplate 或者直接把 Mapper 注入进 Controller 的写法。拿合同审批举例正确的调用链是Controller 收到审批请求参数 - 调 contractService.approve(contractId, operatorId) - Service 里先查合同当前状态判断 DRAFT/APPROVING 才允许审批 - 更新 contract 状态 - 插入 contract_log。如果把事务拆到两个 Mapper 方法里分别在 Controller 调用一旦第二个 Mapper 失败合同状态已经变了但日志没写进去演示一多这种脏数据迟早会暴露。为什么不直接用 Spring Boot 而要守着 SSM 做课设现在很多新项目已经转向 Spring Boot Vue 3但高校毕业设计里 SSM 依然是高频词。换成技术语言看SSM 的价值是让你手工摸过一次 Spring 的 XML 配置、理解 SqlSessionFactory 是怎么注入的这些知识在排查老旧企业内部系统时依然直接用得上。同时这套基于 SSM 的企业合同管理系统如果把 XML 配置换成自动配置迁移到 Spring Boot 的成本并不高属于当面吃亏、后面省事的框架组合。2.3 MyBatis 源码级别的三个执行细节复用演示源码前必须懂网上总有人搜 mybatis 源码真去啃 DefaultSqlSession、Executor 的毕竟是少数但源码机制至少要在写合同模块时明白三件事。第一件是 Mapper 接口为什么没实现类也能被注入MyBatis 启动时会为每个 Mapper 接口生成 JDK 动态代理代理对象内部调用 SqlSession 的方法执行 SQL由于是动态代理Mapper 接口里不能有重载方法两个方法同名只有参数不同会被同一个 namespaceid 搞混。第二件是 SqlSession 的生命周期。SqlSession 不是线程安全的正确用法是每次请求从 SqlSessionFactory 打开一个 SqlSession用完关掉。很多人为了在演示里省事把 SqlSession 做成单例并发量一高就出现连接未关闭、连接池耗尽。在 SSM 整合项目里SqlSessionTemplate 是线程安全的由 mybatis-spring 负责管理你只要保证 Mapper 由 Spring 容器注入不用在业务代码里手动开关。第三件是 useGeneratedKeys 的用法。插入合同后直接contract.getId()拿不到值多半是没在 insert 标签上配这个属性。下面这段是演示源码最常见的写法insert idinsertSelective parameterTypecom.example.contract.entity.Contract useGeneratedKeystrue keyPropertyid insert into contract trim prefix( suffix) suffixOverrides, contract_no, contract_name, amount, status, create_time /trim trim prefixvalues ( suffix) suffixOverrides, #{contractNo}, #{contractName}, #{amount}, #{status}, #{createTime} /trim /insert第一个 trim 拼列名第二个 trim 拼参数占位符suffixOverrides, 负责去掉最后一个多余逗号。useGeneratedKeystrue 表示让 JDBC 把数据库自动生成的主键回填到传入参数的 id 属性上keyPropertyid 指定回填到哪个属性。很多学生只写自增主键插入后再手动查一遍最大值这种方法在并发下会把别人的合同编号拿过来务必用回填而不是 select max(id)。3. 企业合同管理系统的核心模块从 Service 到 SQL 的实现3.1 用 Transactional 把新增合同和写日志绑在一个事务里企业合同管理系统的第一个操作不是查询而是录入。新增合同除了向 contract 表插一条数据通常还要往操作日志表写一条xxx 创建了合同。这两个写操作要么一起成功要么一起失败。Spring 的声明式事务是最省事的方案在 Service 实现类打上注解就行Service public class ContractServiceImpl implements ContractService { private final ContractMapper contractMapper; private final ContractLogMapper contractLogMapper; public ContractServiceImpl(ContractMapper contractMapper, ContractLogMapper contractLogMapper) { this.contractMapper contractMapper; this.contractLogMapper contractLogMapper; } Override Transactional(rollbackFor Exception.class) public int createContract(Contract contract) { // 初始状态必须是草稿不允许调用方直接传一个自定义状态 contract.setStatus(ContractStatus.DRAFT.getCode()); contract.setCreateTime(new Date()); int rows contractMapper.insertSelective(contract); if (rows ! 0) { contractLogMapper.insert(ContractLog.builder() .contractId(contract.getId()) .action(CREATE) .operator(contract.getCreateBy()) .remark(创建合同编号 contract.getContractNo()) .build()); } return rows; } }逻辑说明createContract 先强制把 contract 状态重置为 DRAFT再执行 insertSelective插入成功后拿到回填的 id 写日志。这样一份合同如果缺少必填字段插入失败日志也不会写入如果日志插入失败比如日志表字段长度超限合同本身也会回滚演示时不会出现列表里有合同但日志查不到的半截状态。参数说明rollbackFor Exception.class 是必须写的因为 Spring 默认只在抛出 RuntimeException 时回滚事务如果代码里 catch 到异常后抛了一个普通 Exception事务会照样提交。另一个重点是构造器注入在 Spring 4.3 之后单构造器可以省略 Autowired演示源码为了兼容旧版 Tomcat 也常保留。成员变量上直接 Autowired 不是不能用排查循环依赖和写单测时不如构造器注入干净。3.2 Spring MVC 参数绑定三种接收查询条件的方式合同列表页的筛选条件通常是合同编号、合同名称模糊、状态、签订日期区间。这些条件一起传给后端时最常见也最推荐的做法不是写一堆 RequestParam而是封装一个查询对象 ContractQuery。Spring MVC 会根据请求参数名去匹配 ContractQuery 里的 setter 方法例如前端传 contractNoHT-2024-001它就会调 setContractNo。Controller RequestMapping(/contract) public class ContractController { private final ContractService contractService; public ContractController(ContractService contractService) { this.contractService contractService; } GetMapping(/list) public String list(ContractQuery query, Model model) { // pageNum、pageSize 来自前端表格组件的默认分页参数 PageHelper.startPage(query.getPageNum(), query.getPageSize()); ListContract pageList contractService.queryContractPage(query); PageInfoContract pageInfo new PageInfo(pageList); model.addAttribute(pageInfo, pageInfo); return contract/list; } }这段代码解决了参数绑定、分页、视图返回三个问题。PageHelper.startPage 是 ThreadLocal 机制紧接着的下一条 Mapper 查询会被拦截改写为带 limit 的 SQL所以 startPage 必须和查询语句紧挨着写中间不能有其他查询否则分页会作用到错误的 SQL 上。PageInfo 封装了总记录数、总页数、当前页前端 JSP 里拿 model 的 pageInfo 直接用。接收方式适用场景注意点对象绑定ContractQuery条件多、复用性强setter 必须和参数同名驼峰对齐RequestParam(namecontractNo, requiredfalse)单参数、不常复用required 默认 true漏传直接 400HttpServletRequest.getParameter老项目兜底取出来要自己转类型繁琐3.3 MyBatis 动态 SQL 比 WHERE 11 更适合合同筛选合同筛选条件的难点是用户不一定填全。如果直接拼 SQL用户没填状态时条件就带 status null查不出任何数据用 WHERE 11 拼字符串又容易被条件里的引号搞出 SQL 注入风险。MyBatis 的where标签会在第一个条件成立时自动补 WHERE并且会去掉紧跟其后的 AND是最适合筛选页的结构select idselectByQuery resultTypecom.example.contract.entity.Contract select id, contract_no, contract_name, party_a, party_b, amount, status, sign_date, effective_date, expire_date from contract where if testcontractNo ! null and contractNo ! and contract_no #{contractNo} /if if testcontractName ! null and contractName ! and contract_name like concat(%, #{contractName}, %) /if if teststatus ! null and status ! and status #{status} /if if testbeginDate ! null and sign_date gt; #{beginDate} /if if testendDate ! null and sign_date lt; #{endDate} /if /where order by create_time desc /select逻辑说明每个if都先判断不是 null 且不是空字符串防止用户清空筛选框后传一个空串进来。status 用 DRAFT、EFFECTIVE 这样的字符串SQL 可读性更高如果库里的 status 是 int那if teststatus ! null就够了不需要写 status ! 。参数说明LIKE 查询不能用%#{contractName}%在预编译阶段 #{} 会被替换成占位符 ?外面套的 % 不会包住占位符正确写法是 concat(%, #{contractName}, %)。order by 后面不能用 #{} 做列名参数占位符只能用在值的位置如果要做排序功能建议做一个列名白名单映射。日期比较里gt;和lt;是 XML 对 的转义Mapper XML 中请统一这样写或者用![CDATA[ ]]。4. 演示源码跑通的最小配置数据源、Maven 依赖与首启排错4.1 完整依赖清单一组能直接执行的 pom.xml 核心片段拿到任何一套基于 SSM 的企业合同管理系统源码第一件事是核对 pom.xml。Spring 版本、MyBatis 版本、MyBatis-Spring 适配版本这三个东西只要有一个不匹配启动时就会报各种 NoSuchMethodError。下面是一组能稳定跑通课设演示的组合Spring 5.3.x、MyBatis 3.5.x、mybatis-spring 2.1.x、MySQL 连接器 8.0.x。dependencies !-- Spring MVC 与事务 -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.37/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.3.37/version /dependency !-- MyBatis 官方整合 Spring 的包 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.16/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.1.2/version /dependency !-- MySQL 驱动8.0 之后驱动类名不一样 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- 连接池与 PageHelper 分页插件 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper/artifactId version5.3.3/version /dependency /dependencies依赖版本里最容易埋雷的是 spring-jdbc 和 spring-webmvc 版本不一致一个 5.2.x 一个 5.3.xSpring 容器初始化时会出现注解扫描不到的诡异现象。mybatis-spring 2.x 只支持 MyBatis 3.5如果手头源码用的是 MyBatis 3.4就不要强行升 mybatis-spring 2.x。PageHelper 5.x 的 groupId 是 com.github.pagehelper看到 org.xxx 的旧包名直接换掉。4.2 数据源配置三件套URL、驱动类、连接池SSM 项目的数据源配置一般写在 jdbc.properties 和 spring-mybatis.xml 里。常见错误是只改 MySQL 密码就启动结果报 ClassNotFoundException原因多半是驱动类写成了 com.mysql.jdbc.Driver。8.x 驱动要写 com.mysql.cj.jdbc.Driver同时 URL 里要带 serverTimezone否则日期字段会有时区偏移。jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/contract_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.password123456参数逐个说characterEncodingutf8 保证合同名称里的中文不乱码useSSLfalse 避免 8.x 驱动本地环境报 SSL 连接告警serverTimezoneAsia/Shanghai 如果不加驱动会用默认的 UTC签合同日期比你本地时间早八个小时allowPublicKeyRetrievaltrue 解决 MySQL 8 使用 caching_sha2_password 认证时第一次连接失败的问题。如果本地虚拟机里是 MySQL 5.7驱动类可以回退 com.mysql.jdbc.DriverURL 里不写 timezone 问题不大。接着在 spring-mybatis.xml 里把数据源交给 SqlSessionFactorybean iddataSource classcom.alibaba.druid.pool.DruidDataSource destroy-methodclose property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property nameinitialSize value5/ property namemaxActive value20/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath*:mapper/*.xml/ property nametypeAliasesPackage valuecom.example.contract.entity/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.contract.mapper/ /bean这里体现的是 MyBatis-Spring 的标准整合方式。mapperLocations 指向 resources/mapper 下的 XMLtypeAliasesPackage 注册实体类别名这样 Mapper 里返回类型可以简写为 Contract 而不用每处写全限定名。MapperScannerConfigurer 负责扫描 Mapper 接口并把它们的代理对象交给 Spring 容器管理。4.3 启动失败的三个高频根因与对应检查点SSM 老项目在演示时最容易出现 404、500、中文乱码。遇到 404 先去 web.xml 看 DispatcherServlet 的 url-pattern 是不是写了 /*这会拦截 JSP 请求导致视图解析失败正确写法通常是 /让 Servlet 容器处理 JSP。遇到 500 且日志里出现 invalid bound statement (not found)检查 Mapper XML 的 namespace 是否和接口全限定名一致以及 mapper-locations 是否能扫到对应文件。报错特征优先检查项常见修复ClassNotFoundException: com.mysql.jdbc.Driverpom 依赖、driverClass改为 com.mysql.cj.jdbc.Driver中文乱码JSP 编码、数据库连接 URL统一 UTF-8url 加 characterEncodingutf8Bean named sqlSessionFactory not foundweb.xml 是否加载 spring-mybatis.xml在 contextConfigLocation 里加入配置404 但控制台无异常DispatcherServlet url-pattern把 /* 改成 /还有一个非常隐蔽的问题IDEA 打包或启动时没有把 resources 目录下的 Mapper XML 复制到 classes 目录运行期就报 bound statement not found。解决方法是 pom.xml 里加一段 resources 配置把 src/main/resources 显式纳入构建路径。多数演示源码都默认带着但一旦建了子工程、改了打包插件这段就会被丢掉。5. 把演示源码升级成能过评审的合同系统权限、留痕与验证5.1 用拦截器给合同模块加会话登录校验演示源码里常把登录页做成摆设直接放行所有页面。答辩演示时如果老师顺手点了合同列表的收藏链接绕过登录很容易暴露没做权限。最轻量的做法是写一个 LoginInterceptor拦截 /contract/**只有 Session 里有 loginUser 才放行。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object loginUser request.getSession().getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login); return false; } return true; } }Spring 配置里要注册拦截器并设置排除路径登录接口、静态资源不需要拦截。相比直接引入 Spring Security这种拦截器适合只有登录需求的课设能讲清拦截器工作在 HandlerMapping 之后、Controller 之前。如果老师追问角色权限再在拦截器里判断 loginUser 的 role只有管理员能访问删除接口。5.2 合同变更留痕用一张 log 表讲清谁改了什么企业合同管理系统和普通增删改查的本质差别在于留痕。合同金额被修改后如果连谁改的、改之前是多少都查不到在企业内部是要担责的。演示的 contract_log 表上至少要有 contract_id、action、old_value、new_value、operator_id、create_time 六个字段action 用 CREATE / UPDATE / APPROVE / INVALID 这样的枚举值。实际操作时不要把整条合同记录序列化成 JSON 存进去那样答辩时很难解释检索价值。建议 update 场景只记录变更字段Service 里先查出旧记录再和前端传过来的新值逐字段比较发生变化就把old 旧值 , new 新值 拼进 new_value。这张日志表同时可以作为列表页的操作履历Tab 展示演示数据也会显得真实。5.3 部署到 Tomcat 的最小验证清单验证时用演示账号从浏览器走全流程最直观但建议准备几条 curl 命令方便没有图形界面时快速判断服务状态# 登录并保存 Cookie之后请求带 session 的接口 curl -i -c cookie.txt -d usernameadminpassword123456 http://localhost:8080/contract/login # 分页查询合同列表statusEFFECTIVE 只查生效中的合同 curl -b cookie.txt http://localhost:8080/contract/list?pageNum1pageSize10statusEFFECTIVE第一条命令里的 -c cookie.txt 是把服务端下发的 JSESSIONID 保存到本地文件第二条的 -b 会带上这个 Cookie。验证时应该看到状态码 200、返回列表里只有 statusEFFECTIVE 的数据。这一步过了说明登录拦截、Spring MVC 参数绑定、PageHelper 分页、MyBatis 动态 SQL 这条主链路是通的剩下要做的是把演示数据里的金额和日期刷成近期和答辩 PPT 的截图保持一致。本文还有配套的精品资源点击获取