简介:合同管理系统源码以模块化方式覆盖合同全生命周期管理,面向企业信息化人员与.NET开发工程师,重点解决合同创建、审核、签署、履行及续签终止环节中人工管理效率低、数据易错等问题。压缩包共654个文件,约47.99MB,包含92个cs核心源码、36个resx界面资源、103个dll依赖库、49个png图标及26个exe可执行程序,另有sln解决方案、config配置文件与数据库文件,方便直接打开工程进行调试与二次开发。系统内含合同信息管理、合同扫描电子化、设备信息管理、年度统计数据四大模块,既能快速录入合同编号、签订日期、金额等关键字段并检索更新,也可将纸质合同转为可搜索文档,降低遗失风险。目前已有927人学习下载,适合需要合规存档、设备关联管理或做合同管理定制化项目的团队参考。
1. 合同管理系统源码:把合同台账、审批流和到期预警拧成一套可部署的系统
这份合同管理系统源码解决的问题很具体:企业里合同散落在邮箱和聊天记录里,谁签了、什么时候到期、金额多少全靠人工翻找,审批流程靠口头传达,到期了没人提醒。源码不是演示用的半成品,而是一套覆盖合同起草、审批、归档、到期提醒全流程的可用工程,后端 Spring Boot,前端 Vue 3,跑起来就是一个能日常使用的合同台账系统。适合两类人:一是企业里想快速搭建内部合同管理工具的开发者,二是需要一个基础版本做二次开发、往里面接电子签章或改审批流的项目组。我拆完这套代码最大的感受是,它没有用复杂的工作流引擎和微服务架构,而是用简单的状态机加定时任务,把合同管理最常见的几个痛点处理到位了,代码量不大,思路清晰,拿来改比自己从零写快得多。
2. 技术选型与工程结构:Spring Boot 3 + Vue 3 的前后端分层布局
2.1 后端为什么选 Spring Boot 3 + MyBatis Plus
后端工程基于 Spring Boot 3.x 构建,数据持久层用的是 MyBatis Plus。Spring Boot 负责自动装配和起步依赖,让项目不需要维护一堆繁琐的 XML 配置;MyBatis Plus 则把单表 CRUD、分页查询、条件构造这些高频操作全部内置,合同台账列表这种典型的分页查询接口基本不用手写 SQL。我在实际项目里也倾向这种组合:启动快、依赖少、新人接手不会在配置阶段卡太久,能把精力放在业务逻辑上。
除了基本的 Web 和 ORM 依赖,工程还引入了 Redis 承担两类职责:一是合同编号的并发生成,二是登录会话的缓存。Redis 在这里不是可有可无的点缀——合同台账是多人同时录入的场景,合同编号必须全局唯一而且要有业务含义,靠数据库自增做不到“日期前缀+当日序号”这种格式,用 Redis 的自增命令生成序列是常见解法。会话缓存则是配合前端路由守卫,登录成功后把用户信息和角色写入 Redis,前端请求带上 token,后端从缓存里取身份鉴权。
安全方面,工程没有引入 Spring Security 全家桶,而是自己写了一个拦截器实现登录校验和接口白名单。这个取舍在中小型内部系统里很合理:Spring Security 的过滤器链和配置体系学习成本高,内部系统的权限模型通常就管理员、法务、普通员工几种角色,自行实现反而更直观、更容易按业务定制。合同管理本身就是企业内网应用,不暴露到公网,安全重心放在登录鉴权和数据权限上就够了。
2.2 前端工程:Vue 3 + Element Plus 的权限与表单
前端是标准的 Vue 3 单页应用,构建工具用的 Vite,UI 组件库选的 Element Plus。页面主要分成三块:合同台账列表页、合同审批详情页、系统管理页。台账列表页是最核心的界面,支持按合同名称、类型、状态、到期日期区间筛选,数据表格展示合同编号、签订方、金额、到期日等关键字段;审批详情页按时间线展示审批记录,附件列表支持在线预览和下载;系统管理页负责用户和角色的维护。
权限控制做了两层:路由守卫控制菜单可见性,按钮级别用自定义指令控制。登录用户从后端拿到自己的角色编码,路由表里每个页面配置需要的角色,不匹配就重定向到无权限页。按钮权限的实现思路是注册一个 v-permission 指令,指令内部判断当前用户权限列表里是否包含该按钮所需的权限标识,不包含就直接移除 DOM 节点。这套做法在中小型管理系统里是主流方案,不需要引入专门的权限框架,改动成本低。
Element Plus 用在合同表单上的体验比较顺。合同创建页面包含合同编号、合同类型、签订方、合同金额、签订日期、到期日期、附件上传等字段,日期选择器、级联选择器、上传组件都是现成的。源码对表单做了统一的校验规则封装,比如合同金额必须大于 0、到期日期不能早于签订日期,这些业务校验放在前端先拦一层,能明显减少无效请求打到后端。后端同样有对等的校验逻辑,前端校验只是为了用户体验,后端校验才是数据安全的关键。
2.3 目录结构:从 controller 到 mapper 的四层落点
拿到底层源码,先看目录结构再跑起来,比急着启动更重要。后端工程按 com.company.contract 包名组织,controller、service、mapper、entity、dto、config、common 各司其职;前端 src 下是 api、router、store、views、components 五个目录。关键目录落点如下:
contract-admin/ ├── backend/ │ ├── src/main/java/com/company/contract/ │ │ ├── controller/ # 接口层,合同、审批、提醒、用户四个模块 │ │ ├── service/ # 业务层,含 impl 实现类 │ │ ├── mapper/ # MyBatis Plus 的 Mapper 接口 │ │ ├── entity/ # 数据库实体类 │ │ ├── dto/ # 请求响应对象 │ │ ├── config/ # 安全、Redis、定时任务配置 │ │ └── common/ # 统一返回结果、异常处理 │ └── src/main/resources/ │ ├── application.yml # 数据源、Redis、文件上传配置 │ └── db/contract.sql # 初始化表结构和种子数据 └── frontend/ ├── src/ │ ├── api/ # 按模块拆分的 HTTP 请求 │ ├── views/ # 合同台账、审批、统计页面 │ ├── router/ # 路由和页面守卫 │ └── store/ # Pinia 状态管理 └── package.jsonconfig 包里有三个配置类值得提前认识:SecurityConfig 负责登录拦截和接口白名单,RedisConfig 定义 RedisTemplate 的序列化方式,ScheduleConfig 开启定时任务扫描。SecurityConfig 里配置了登录接口和静态资源不拦截,其余 /api/** 请求必须携带有效 token。这个结构最大的优点是分层干净:接口层不写业务逻辑,业务层不碰 SQL,mapper 只负责数据库交互。想调整审批条件就去 Service 层改;想换一套数据库方言,改 Mapper 里的注解即可。前端部分更清晰,views 和 api 一一对应,新增页面时复制一个模块改改路径就能出活。
3. 核心模块拆解:一个合同从起草到归档的完整链路
3.1 合同起草:编号生成与附件上传
合同起草是整套流程的起点。前端表单提交后调用 /api/contract/create 接口,后端 ContractController 接收请求,Service 层依次完成三件事:生成合同编号、保存合同基本信息、关联上传的附件。合同编号生成是整个模块最值得讲的地方,核心代码很简洁:
@Override public String generateContractNo(String contractType) { String prefix = contractType + DateUtil.format(new Date(), "yyyyMMdd"); Long seq = redisTemplate.opsForValue().increment("contract:seq:" + prefix); return prefix + String.format("%04d", seq); }逻辑说明:前缀由合同类型编码加当天日期组成,比如 XS 是销售合同、CG 是采购合同;再通过 Redis 的 INCR 命令对「当天前缀」这个 Key 做自增,得到一个从 1 开始的当日序号;最后用 %04d 补足四位,生成的编号形如 XS202501150012。这套编号包含三个信息:合同类型、签署日期、当日顺序号,业务方看一眼编号就知道合同归属。参数 contractType 是合同类型编码,由前端下拉框传入;sequence 从 Redis 获取,天然具备并发安全,多个用户同时创建合同不会撞号。
附件上传走的是另一个接口,请求类型是 multipart/form-data,文件保存到本地配置的 upload 目录,文件元数据写入合同附件表。上传这里有实现细节:文件名做了 UUID 重命名,防止中文文件名乱码以及同名文件互相覆盖,原始文件名单独存一列用于下载时展示。我在项目里处理上传都会遵守这个约定——任何用户可控的文件名都不能直接落盘,否则线上迟早会出同名文件互相覆盖的问题,到时候恢复数据是个大麻烦。
合同基本信息的保存逻辑也不复杂,但注意 ContractDTO 里加了校验注解,金额字段用的是 BigDecimal 而不是 Double。这个选择的原因很直接:金额计算和存储必须精确,Double 的浮点误差在金额对比和统计时会翻车。所有涉及钱的字段,从 entity 到 dto 全部用 BigDecimal,数据库对应 decimal(18,2),这是做管理系统的基本功。
3.2 审批流:一张审批记录表撑起的状态机
审批是这个系统里最有含金量的模块。它没有引入 Activity 这类重量级流程引擎,因为合同审批的场景相对固定,用流程引擎反而增加部署和学习成本。源码用一张合同审批记录表加合同表上的状态字段手动实现了一个状态机:合同状态分为草稿、审批中、已通过、已驳回、已归档,每个状态允许发生的动作是固定的,比如草稿可以提交审批,审批中可以审批通过或驳回,只有已通过的状态才能归档。
状态机最怕的是并发问题。两个审批人同时打开同一条合同,A 先点了通过,B 后点了驳回,如果后端不做任何控制,最后结果就是 B 覆盖了 A——合同被驳回了,但 B 其实只是基于过期页面做的操作。源码处理和这个问题相关的核心代码:
@Override @Transactional(rollbackFor = Exception.class) public void approve(ApprovalRequest request) { Contract contract = contractMapper.selectByIdForUpdate(request.getContractId()); if (contract == null || !"审批中".equals(contract.getStatus())) { throw new BizException("合同状态已变化,请刷新后重试"); } contract.setStatus("已通过"); contractMapper.updateById(contract); ApprovalRecord record = new ApprovalRecord(); record.setContractId(contract.getId()); record.setApproverId(request.getApproverId()); record.setAction("通过"); record.setComment(request.getComment()); approvalRecordMapper.insert(record); }逻辑说明:selectByIdForUpdate 使用数据库行级锁,同一时间只有一个审批请求能读到这条合同记录,从源头避免两个人同时审批导致的状态覆盖;事务注解保证合同状态更新和审批记录插入要么同时成功,要么一起回滚。代码里先查合同再检查状态,状态不对直接抛业务异常,前端会弹出提示让用户刷新页面。我第一次做审批功能时没加行锁,结果两个审批人同时点击,最后合同状态被后提交的人覆盖,这种血泪经验现在还记得,凡是涉及状态流转的更新操作,一定要考虑并发。
审批记录表保存完整的操作历史,包含审批人、动作、意见、时间四个字段。合同详情页按时间倒序展示这条记录链,对审计很有价值——企业法务查合同时,能直接看到是谁在什么时间做了哪个审批动作。这套设计不依赖任何外部服务,数据都在自己库里,出现问题排查也方便。
3.3 到期预警:定时任务怎么避免重复执行
合同到期提醒是用户感知最强的功能。系统每天凌晨跑一次定时任务,扫描未来 30 天内到期以及已经过期的合同,给合同责任人发送提醒消息。实现用的是 Spring Boot 自带的 @Scheduled 注解,没有引入 XXL-Job 这类分布式调度中心,单机部署场景下够用且省事:
@Scheduled(cron = "0 0 2 * * ?") public void remindExpiringContracts() { String lockKey = "contract:remind:lock"; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(30)); if (Boolean.FALSE.equals(locked)) { return; } List<Contract> contracts = contractMapper.selectExpiringContracts(30); for (Contract contract : contracts) { messageService.sendRemind(contract); } }逻辑说明:先通过 Redis 的 setIfAbsent 尝试获取一把锁,获取成功才继续执行扫描,获取失败说明另一个实例正在执行,直接跳过。这段代码是防止以后从单机部署扩展成多实例时,多个节点同时跑任务导致同一合同收到多条重复提醒。锁设了 30 分钟过期时间,避免任务执行过程中抛异常导致锁永久不释放,后续任务全部被卡死;扫描条件封装在 selectExpiringContracts 里,SQL 判断合同结束日期在当天和当前日期加 30 天之间。
参数 30 是提前提醒的天数,改成 15 或 60 只需调整这一处。另外提醒内容做成了消息模板,包含合同名称、到期日期、剩余天数,通过站内信发送给合同责任人。实际用下来,这种自动预警比人工翻台账高效得多,也是最容易获得业务方认可的功能。如果以后要接企业微信或钉钉推送,只需要替换 messageService 的 sendRemind 实现,模板和扫描逻辑都不用动。
4. 本地跑通:从导入 SQL 到前后端联调的环境与步骤
4.1 环境准备:JDK、MySQL、Redis 三个前提
跑这套源码前先把基础环境确认一遍,缺一个都会在中途卡住。后端需要 JDK 17 以上,因为 Spring Boot 3 基于 Jakarta EE,JDK 8 直接跑不起来;数据库用 MySQL 5.7 或 8.0,初始化脚本兼容两种版本;Redis 建议 5.x 以上,功能上没什么特殊要求。前端需要 Node.js 16 以上,包管理器用 npm 或 pnpm 均可。
MySQL 和 Redis 的安装方式按操作系统来,macOS 可以用 Homebrew,Linux 用包管理器,Windows 下载安装包。MySQL 初始化时务必选 UTF-8 字符集,否则导入中文数据后查询会乱码。数据库创建也有一个细节:初始化脚本 contract.sql 里没有 CREATE DATABASE 语句,需要先手动建一个契约库再导入,比如通过命令行执行 mysql -uroot -p -e "CREATE DATABASE contract_db DEFAULT CHARACTER SET utf8mb4;" 再导入脚本,接口路径和库名以脚本为准。
4.2 启动后端:修改配置文件的三个必填项
后端启动前需要修改 application.yml 里三处配置:MySQL 连接信息、Redis 连接信息、本地文件上传路径。改完这几项才能正常连接依赖服务,配置修改示例:
spring: datasource: url: jdbc:mysql://localhost:3306/contract_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_database_password redis: host: localhost port: 6379 password: your_redis_password_if_any file: upload: path: /data/contract-upload参数说明:url 中 serverTimezone 必须显式设置,MySQL 8.x 驱动默认要求指定时区,否则启动直接报错;username 和 password 改成你本地的数据库账号;file.upload.path 是合同附件的保存目录,Windows 环境建议改成 D:/contract-upload 这种绝对路径。Redis 如果没有设置密码,password 那一行可以注释掉,留着空字符串反而可能触发认证错误。
配置项对应关系存在几个常见误用:有人把 Redis 的 host 写成 localhost 但实际 Redis 跑在 Docker 容器里,导致连接失败;也有人把上传目录写成相对路径,服务重启后文件全部丢失。改造路径用绝对路径并确认目录有写权限,可以在项目根目录放一个 .gitkeep 占位,避免空目录被版本控制忽略。
改完配置后,在 backend 目录执行 mvn spring-boot:run 启动。看到日志输出 Started Application 就表示启动成功,不要只看到 Tomcat started on port 8080 就以为万事大吉,要等 Spring 上下文完全刷新。启动过程中最常见的报错就是数据库或 Redis 连不上,表现为 CommunicationsException 或 Unable to connect to Redis,先确认两个服务进程是否在运行、端口是否被占用,再用命令行客户端手动连一下,问题基本能定位。
4.3 启动前端:环境变量与代理配置
前端启动相对省心。在 frontend 目录下先安装依赖再启动开发服务器,项目内置了开发代理,前端请求 /api 开头的接口会自动转发到本地的 8080 后端端口,所以本地开发不需要处理跨域。标准启动命令组合:
cd frontend npm install npm run devnpm install 如果报错,先检查 Node 版本是否过低,Vue 3 和 Vite 对 Node 版本有明确要求,太旧的版本装依赖会失败。npm run dev 启动后访问 http://localhost:5173 就能打开登录页。用初始化脚本里的测试账号登录,账号 admin,密码 admin123,登录后能看到合同台账、审批中心、到期提醒三个主菜单,数据列表里有初始化脚本预置的几条示例合同。
如果前端登录后接口全部报 401,大概率是后端没启动或者代理指向不对。打开浏览器开发者工具看 Network 面板,确认请求落在了哪个地址,再对照 Vite 配置文件里的 proxy 设置排查。整个联调链路通了之后,顺一遍核心流程:建一条合同、传一个附件、提交审批、审批通过、看到期提醒,可以发现这套系统日常使用链路非常短,业务人员培训成本很低。
跑通之后建议做三件事验证系统完整可用:第一,用测试账号创建合同并走完提交、审批、归档全流程;第二,把合同到期日改成明天,手动触发一次提醒任务,确认消息发送逻辑正常;第三,查看审批记录表,确认每一步操作都已落库。三个动作都通过说明环境没有问题,后面改代码心里才有底。
5. 避坑指南:跑这套合同管理源码常见的七个问题
5.1 合同编号重复或冲突
现象:创建合同时报数据库唯一索引冲突,或者生成的编号和已有数据重复。
原因:本地开发时 Redis 只做缓存没有持久化,重启后自增计数被重置,新生成的编号从 1 开始,很容易和已有编号撞车。
解决:给 Redis 开启 AOF 持久化,关闭服务器时数据不丢;更稳妥的做法是在系统初始化时把 Redis 里编号自增的初始值设为数据库当前最大序号,这样即使 Redis 重置也能衔接上。线上环境我建议两个都做,编号这种东西一旦乱了很难人工修复。
5.2 审批后合同状态不是预期值
现象:两个审批人同时打开同一条合同,A 先通过,B 后驳回,最后状态是驳回,但 B 并没有真的想驳回。
原因:前端页面打开时拉取的是旧状态,后端没有做并发控制,后提交的请求直接覆盖了前一个结果。
解决:源码里用行级锁解决了这个问题,但如果你在二次开发时改动了审批逻辑,务必保证查询状态和更新状态在同一个事务里,更新语句带上当前状态作为条件。这两个细节少任何一个,并发下必然出问题。
5.3 上传的附件下载时 404
现象:附件上传成功,列表里也能看到文件名,但点击下载提示文件不存在。
原因:文件上传路径配置的是相对路径或临时目录,服务一重启文件就被清理了。
解决:把 file.upload.path 改成固定的绝对路径,并确认该目录有读写权限。另外存储附件信息时不能只存文件名,要把文件相对上传目录的完整路径存下来,否则同一天上传的同名文件会互相干扰。
5.4 定时提醒任务不执行
现象:合同到期了,责任人没有收到任何提醒。
原因:一种情况是服务没启动;另一种情况是 Redis 分布式锁的 Key 一直存在没有释放,后续任务全被拦在锁外。
解决:先看日志里有没有任务执行的记录,再检查 Redis 中 contract:remind:lock 这个 Key 是否存在。排查到锁占住不放的情况,多数是任务执行中抛出异常没走释放逻辑,给任务体加上 try-finally 确保锁最终能被删除。这个坑属于定时任务的经典问题,多实例部署时尤其注意。
5.5 前端打包部署后接口全部 404
现象:本地开发一切正常,npm run build 后部署到 Nginx,登录接口全部 404。
原因:开发环境的代理配置在生产环境不生效,打包后的前端发出的是同源请求,Nginx 没有将 /api 反向代理到后端接口。
解决:在 Nginx 站点配置里加一条 location /api 的反向代理规则,把前端请求转发到后端服务。这个坑很常见,本质上就是开发时浏览器代理帮做的事,部署后要由 Nginx 来做。
5.6 审批记录时间线顺序错乱
现象:合同详情页审批记录忽前忽后,看不出真实顺序。
原因:查询语句没有按时间排序,或者时间字段用了字符串格式,排序时按字典序而非时间先后排列。
解决:审批记录表时间字段用 datetime 类型,查询时 ORDER BY create_time ASC。所有涉及时间线展示的查询都建议数据库排序而不是内存排序,数据量大了才能看出差距。
5.7 合同台账搜索变慢
现象:合同数据达到几万条后,按名称搜索明显卡顿。
原因:合同名称字段没有建索引,搜索是全表扫描。
解决:给合同表的合同名称字段加一个普通索引,如果按类型和状态筛选很频繁,这几个字段也一并建复合索引。索引不是越多越好,按实际查询频次来加,避免拖慢写入性能。
6. 二次开发:三个最值得改的扩展点
6.1 接入电子签章服务
这套源码目前是内部审批闭环,没有对接外部电子签章平台。改造思路在审批通过的逻辑后增加一个签章触发点:合同状态变为已通过后,自动调用签章服务的 API,上传合同 PDF 文件并获取签署链接。推荐用 Spring 的事件机制做解耦,审批通过后发布一个 SignContractEvent,签章监听器收到事件后异步处理,不阻塞主流程。这样改的好处是原有审批逻辑完全不动,未来换签章服务商只需要新增一个监听器实现。签名密钥不要写死在代码里,放到环境变量或配置中心,代码一旦泄漏不至于连密钥一起泄露。
6.2 审批流改成多级会签
当前是单级审批,合同提交后只有一个审批人处理。改成多级会签需要动两个位置:合同表加一个当前审批层级字段,审批记录表加一个批次号字段。提交审批时生成一个批次,批次内包含多个审批人;每个审批人通过后,检查同一批次内是否还有人未处理,全部通过才推进到下一级。关键判断逻辑:
public void batchApprove(String batchNo, Long approverId) { List<ApprovalRecord> records = approvalRecordMapper.selectByBatchNo(batchNo); boolean allDone = records.stream().allMatch(r -> "已处理".equals(r.getStatus())); if (allDone) { Contract contract = contractMapper.selectById(records.get(0).getContractId()); contract.setApproveLevel(contract.getApproveLevel() + 1); contractMapper.updateById(contract); } }逻辑说明:每次审批人处理完自己的记录,统计同一批次下所有记录的完成状态,只有全部已处理才把合同的审批层级加一,进入下一级审批。参数 batchNo 是批次号,创建审批流程时生成;approverId 是当前处理人。这个改造也验证了源码解耦设计的价值——审批记录表独立于合同表,扩展字段不会影响合同模块稳定。
6.3 数据权限按部门隔离
默认实现是所有登录用户能看到全部合同,在一家公司里这往往不合适。改造做法是合同表增加一个部门 ID 字段,用户在查询台账时只能看到本部门的数据。建议在 Mapper 层通过 MyBatis Plus 拦截器统一拼接部门条件,而不是在每个业务方法里手写判断——数据权限这种东西一定要在底层拦,靠业务层自觉会在某一次需求迭代中漏掉一个接口,造成越权访问。我做完这类改造后有一个很深的认知:查询条件的统一收敛越早做越好,等接口多了再回头补权限过滤,工作量翻倍。从那以后,我每次接手管理系统,第一件事就是先把数据权限的查询条件定义清楚再写业务。希望帮到你。
本文还有配套的精品资源,点击获取