简介合同管理系统源码是一套面向企业信息化管理人员与C#开发者的完整解决方案围绕合同全生命周期管理涵盖合同信息录入、扫描电子化、设备信息关联及年度统计等功能模块适合需要实现合同电子化归档与风险管控的团队参考或二次开发。压缩包共654个文件大小47.99MB包含cs源码、dll动态库、resources资源文件、config配置文件和数据库文件等分别对应程序主体、依赖库、界面资源、运行参数与业务数据目录按模块组织便于查阅和编译调试。目前已有927人学习下载。通过学习该源码可理解合同信息管理、图像扫描处理、设备台账管理和统计报表生成的实现思路掌握从数据库接口到前端UI的完整代码组织方式模块化设计也更利于开发人员在此基础上扩展审批流、提醒预警等自定义功能从而快速搭建符合自身业务需求的合同管理系统。1. 合同管理系统源码别急着找代码先想清楚这三件事用搜索引擎找合同管理系统源码一搜一大把但能拿回公司内网直接跑起来的很少。我接过几轮这类活的体验是合同管理系统真正难的地方不在增删改查而在数据模型能不能扛住财务对账、审批流能不能按公司规则走完闭环、附件文件和合同元数据会不会失联。市面上很多合同管理系统源码要么工程老旧要么只覆盖“能存能查”一上线就露馅。这篇文章我把一套可落地的最小方案按顺序拆开讲从数据库设计、Spring Boot 后端接口到 Vue3 前端页面和审批流实现再到实际踩过的坑。适合手头要快速上线合同管理、又不想被重型工作流引擎绑架的开发和实施人员。2. 先拆业务再写代码合同管理系统的数据模型和状态机设计2.1 最小可用集是四张表合同主表、审批记录、附件和付款计划很多合同管理系统源码一上来就是十几张表权限、菜单、角色、流程定义铺满整个库结果核心的合同台账反而做得粗糙。合同管理系统的本质是“一张主表加附属信息”与其把模型铺大不如把基础结构做扎实。我一般建议先建四张表合同主表contract_main、审批记录表contract_approval_record、附件表contract_attachment、付款计划表contract_payment_plan再配一个合同类型字典表。合同主表是唯一的核心所有列表查询和状态流转都从它出发。下面是我常用的建表 SQL关键字段都加了注释CREATE TABLE contract_main ( id bigint NOT NULL AUTO_INCREMENT, contract_no varchar(64) NOT NULL COMMENT 合同编号业务上唯一, contract_name varchar(255) NOT NULL COMMENT 合同名称, party_a varchar(255) NOT NULL COMMENT 甲方名称, party_b varchar(255) NOT NULL COMMENT 乙方名称, contract_type varchar(32) DEFAULT NULL COMMENT 合同类型编码关联字典表, total_amount decimal(18,2) NOT NULL DEFAULT 0.00 COMMENT 合同总金额注意精度, currency_code char(3) NOT NULL DEFAULT CNY COMMENT 币种 ISO 编码, tax_rate decimal(5,2) DEFAULT NULL COMMENT 税率百分比备用字段, sign_date date DEFAULT NULL COMMENT 签订日期, effective_date date DEFAULT NULL COMMENT 生效日期, expire_date date DEFAULT NULL COMMENT 到期日期提醒任务依赖它, status tinyint NOT NULL DEFAULT 0 COMMENT 0草稿 1审批中 2已通过 3已驳回 4已终止 5已归档, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号防并发审批, created_by bigint DEFAULT NULL COMMENT 创建人ID, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted tinyint NOT NULL DEFAULT 0 COMMENT 逻辑删除标记业务单据不能物理删, PRIMARY KEY (id), UNIQUE KEY uk_contract_no (contract_no), KEY idx_status_expire (status, expire_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT合同主表;这段 SQL 有几个点值得说透。contract_no上做了唯一索引但只是兜底真正不重复要靠 Service 层的发号器total_amount用decimal(18,2)而不是浮点类型理由下一节展开status就代表了状态机不需要单独建状态表deleted做逻辑删除合同这种要留痕的单据不能物理删否则后续审计和追溯都无从谈起。附件表保存的是文件在磁盘或对象存储上的相对路径而不是文件内容本身。审批记录表保存每次提交、通过、驳回的流水这几张表用contract_id关联到主表即可。先把这个最小模型立住后续要加收付款计划、开票记录、关联项目时再做扩展比一开始就铺大摊子好维护得多。2.2 金额精度和合同编号两个最容易给源码埋雷的位置先说金额。不少老源码里total_amount用的是double或float存入 MySQL 超过一定位数就有精度漂移对账时差一分钱都解释不清。这个坑有三个层面要堵数据库字段用decimal(18,2)Java 实体类用BigDecimal前端输入框用数字输入并限制精度。三层任何一层用了浮点最后对账都可能翻车。合同编号看着简单并发一高就出问题。常见格式是“前缀 日期 自增号”比如HT-20250412-001。如果直接在主表上查max(id)加一两个请求同时进来就会生成相同编号第二个请求被唯一索引拦截用户看到的就是“保存失败”或“系统错误”体验很差。我常用的办法是单独建一张发号器表CREATE TABLE contract_no_seq ( seq_date char(8) NOT NULL COMMENT 日期 yyyyMMdd, seq_value int NOT NULL DEFAULT 0, PRIMARY KEY (seq_date) ) ENGINEInnoDB COMMENT合同编号发号表;生成编号时执行UPDATE contract_no_seq SET seq_value seq_value 1 WHERE seq_date ?再查回来拼完整编号。行锁只压在这张表的一条记录上不会影响合同主表的插入并发。这个办法比 Redis INCR 更省运维小团队完全够用。2.3 审批状态机状态字段加流水表不急着上工作流引擎很多合同管理系统源码一谈审批就引入 Flowable、Activiti光配置表就十几张。合同审批的本质是一条固定链路发起人提交、部门主管、法务、财务、总经理各公司差异只在节点顺序。引入工作流引擎对这类场景是过度设计排查问题也更绕。我倾向于用状态字段 审批记录表 节点配置表来实现。状态迁移规则要收敛在 Service 层由统一接口校验。下面的矩阵就是我的实现依据当前状态可触发操作目标状态0 草稿提交审批1 审批中1 审批中最后节点通过2 已通过1 审批中任一节点驳回3 已驳回2 已通过手动终止4 已终止2 已通过到期归档5 已归档3 已驳回修改后重新提交1 审批中这个矩阵看着简单实际最容易漏的是“审批中”状态被两个审批人同时处理。我在 Service 里用带条件的更新语句做乐观锁UPDATE contract_main SET status ?, version version 1 WHERE id ? AND version ?更新行数为 0 就说明合同已被别人动过直接提示刷新后查看。审批记录表负责把每一笔操作留痕谁在哪个节点做了什么操作后面审计时一查就有。审批节点配置单独存一张表字段包括node_code、node_name、order_no、approver_role提交审批时解析出第一个待办人即可不需要单独建待办任务表。提示不要把“当前待办节点”直接存进合同主表。主表只反映合同整体状态待办状态应该由“审批记录表的最后一条 节点配置表”实时推导否则会出现两处状态不一致谁也说不清到底是哪份数据为准。3. 后端实现用 Spring Boot 跑通合同录入、审批和列表查询3.1 技术选型Spring Boot 2.7 MyBatis-Plus不选 JPA 的理由合同管理系统的后端核心是列表查询和状态流转SQL 的动态性和复杂度都比较高。Spring Boot 是国内内网项目的主流选择ORM 我一般用 MyBatis-Plus 而不是 JPA。MyBatis-Plus 的分页插件对 MySQL 非常友好逻辑删除、乐观锁、自动填充都是内置注解JPA 的实体关系映射在这种多条件检索场景下反而容易写出连表多、条件绕的慢查询。最小依赖组合如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency /dependenciesSpring Boot 选 2.7.x 而不是 3.x主要考虑 JDK 版本兼容。很多公司的生产环境还停在 JDK 83.x 强制要求 JDK 17升级牵连太广。starter-validation一定要加金额、日期、编号的入参校验都靠它省得在 Controller 里写一堆 if。3.2 实体类和 MapperBigDecimal、LocalDate 加两个关键注解实体类直接对应contract_main。Java 侧要避开两个常见错误金额字段用Double、日期字段用String。前者造成浮点误差后者让状态比较和日期排序变得很别扭。正确写法如下Data TableName(contract_main) public class ContractEntity { TableId(type IdType.ASSIGN_ID) private Long id; private String contractNo; private String contractName; private String partyA; private String partyB; private String contractType; private BigDecimal totalAmount; private LocalDate effectiveDate; private LocalDate expireDate; private Integer status; Version private Integer version; TableLogic private Integer deleted; private LocalDateTime createdAt; private LocalDateTime updatedAt; }TableLogic是逻辑删除开关开启后 MyBatis-Plus 生成的所有单表 SQL 都会自动追加deleted 0避免手写where漏过滤。Version是乐观锁注解配合分页插件里的 OptimisticLockerInnerInterceptor 生效后面审批接口会用到。创建时间和更新时间我建议依赖数据库的默认值和ON UPDATE应用层少管一点就能少踩“时间没赋值”的 bug。3.3 Service 层事务边界创建合同不是一次 insert创建合同涉及三件事写主表、生成编号、插入创建流水。这三件事必须在一个事务里完成否则会出现主表写进去了、编号没生成或者流水缺失的情况。下面是一个缩略的 Service 方法Service public class ContractService { Autowired private ContractMapper contractMapper; Autowired private ContractNoSeqMapper seqMapper; Autowired private ApprovalRecordMapper approvalMapper; Transactional(rollbackFor Exception.class) public Long createContract(ContractCreateDTO dto) { if (dto.getTotalAmount().compareTo(BigDecimal.ZERO) 0) { throw new BizException(合同金额不能为负数); } String dateStr LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String contractNo seqMapper.nextNo(dateStr); ContractEntity entity new ContractEntity(); entity.setContractNo(contractNo); entity.setContractName(dto.getContractName()); entity.setPartyA(dto.getPartyA()); entity.setPartyB(dto.getPartyB()); entity.setTotalAmount(dto.getTotalAmount()); entity.setStatus(0); contractMapper.insert(entity); // 写入一条创建流水记录创建人、创建时间、初始状态 contractLogService.log(entity.getId(), CREATE, null, 0); return entity.getId(); } }事务放在 Service 层避免 Controller 里直接调用 Mapper 导致事务失效。异常统一抛自定义的BizException配合全局异常处理器返回给前端的就是带业务提示的 JSON而不是 500 页面。nextNo就是这个项目里发号器表对应的更新方法核心是那条UPDATE contract_no_seq SET seq_value seq_value 1 WHERE seq_date ?事务回滚后编号可能空洞但空洞可以接受重复不能接受。3.4 审批接口乐观锁防止双人同时审批审批是合同管理里最容易出现并发问题的接口。两个审批人同时打开同一份待办分别点了“通过”如果不做版本控制就会出现两条通过记录、状态被后写覆盖。解决办法就是设计表时预留的version字段。Transactional(rollbackFor Exception.class) public void approve(Long contractId, Long approverId, String comment) { ContractEntity current contractMapper.selectById(contractId); if (current null || current.getStatus() ! 1) { throw new BizException(合同当前状态不允许审批); } LambdaUpdateWrapperContractEntity wrapper new LambdaUpdateWrapper(); wrapper.eq(ContractEntity::getId, contractId) .eq(ContractEntity::getStatus, 1) .eq(ContractEntity::getVersion, current.getVersion()); wrapper.set(ContractEntity::getStatus, 2); wrapper.set(ContractEntity::getVersion, current.getVersion() 1); int updated contractMapper.update(null, wrapper); if (updated 0) { throw new BizException(该合同已被其他审批人处理请刷新后查看); } approvalMapper.insert(new ApprovalRecord(null, contractId, approverId, comment)); }这里的核心不是先查再改而是把“状态为审批中”和“版本号匹配”写进更新条件。数据库行锁保证后一个请求的更新行数为 0直接拒绝。如果是多级审批还要在流水表里记录当前节点编码否则“通过了第一级、第二级怎么办”就丢了。驳回接口逻辑类似只是把目标状态改成 3并记录驳回意见。3.5 合同列表查询多条件检索的分页 SQL列表页是用户面对最多的东西查询 SQL 要谨慎。最容易翻车的是连了附件表之后一条合同带三个附件就被展开成三行分页直接乱掉。我一般用子查询统计附件数而不是 joinselect idselectContractPage resultTypecom.example.dto.ContractVO SELECT cm.id, cm.contract_no, cm.contract_name, cm.party_a, cm.party_b, cm.total_amount, cm.status, cm.expire_date, (SELECT COUNT(*) FROM contract_attachment a WHERE a.contract_id cm.id AND a.deleted 0) AS attachment_count FROM contract_main cm WHERE cm.deleted 0 if teststatus ! null AND cm.status #{status} /if if testkeyword ! null and keyword ! AND (cm.contract_no LIKE CONCAT(%, #{keyword}, %) OR cm.contract_name LIKE CONCAT(%, #{keyword}, %)) /if if testexpireStart ! null AND cm.expire_date gt; #{expireStart} /if if testexpireEnd ! null AND cm.expire_date lt; #{expireEnd} /if ORDER BY cm.id DESC /select子查询在数据量到十万级之前性能可以接受而且行数不会因为一对多关联而膨胀。gt;和lt;是 XML 里的转义写法直接写会解析报错这是新手经常卡住的地方。分页插件会自动把这条 SQL 包一层 COUNT 查询总数也是准的。4. 前端落地Vue3 Element Plus 搞定列表、表单和附件上传4.1 网上合同系统源码多停在 Vue2用 Vue3 重写要注意这些差异开源的合同管理系统源码大多还是 Vue2 那一代写法filter语法、$refs、$on在 Vue3 里都变了。我建议直接用 Vue3 Vite Element Plus 起新工程目录就三块views/contract/list.vue、views/contract/edit.vue、api/contract.js不需要上 Pinia合同管理这种场景 Props 加事件足够。列表页最关键的是搜索条件和分页数据保持同一数据源script setup import { ref, onMounted } from vue import { queryContractPage } from /api/contract const loading ref(false) const pageNo ref(1) const pageSize ref(10) const total ref(0) const tableData ref([]) const queryForm ref({ status: null, keyword: , expireStart: null, expireEnd: null }) async function fetchPage() { loading.value true const resp await queryContractPage({ ...queryForm.value, pageNo: pageNo.value, pageSize: pageSize.value }) tableData.value resp.records total.value resp.total loading.value false } function handleSearch() { pageNo.value 1 fetchPage() } onMounted(fetchPage) /script注意搜索时把pageNo重置为 1这是前端最常见的翻车点。在第 3 页输入关键字搜索接口把页码 3 传过去查出来自然是空的。el-pagination的current-change和size-change事件都要重新拉列表size-change之后同样把页码重置为 1否则会出现页码超出总页数的问题。4.2 合同编辑表单金额输入框要控精度日期要有联动校验合同表单的金额字段不要用普通文本框容易输入负数或超过两位小数。Element Plus 的el-input-number可以控制精度配合el-form的 rules 做整体校验。生效日期早于到期日期这个关系也在前端拦一道体验更好const formRules { contractName: [{ required: true, message: 合同名称必填, trigger: blur }], totalAmount: [ { required: true, message: 金额必填, trigger: blur }, { type: number, min: 0, message: 金额不能为负数, trigger: blur } ], effectiveDate: [{ required: true, message: 生效日期必填, trigger: change }], expireDate: [ { required: true, message: 到期日期必填, trigger: change }, { validator: (rule, value, callback) { if (formRef.value.effectiveDate value formRef.value.effectiveDate) { callback(new Error(到期日期必须晚于生效日期)) } else { callback() } }, trigger: change } ] }自定义 validator 里读取其他字段时要确保那个字段已经绑定到表单对象上。如果表单是分步填写的最后一步校验时前面的字段可能还没赋值所以要在进入该步骤时先做一次表单赋值合并。这个坑我在改造同事代码时见过不止一次页面提示“到期日期必须晚于生效日期”但生效日期明明填了原因就是校验函数里取到的值是 undefined。4.3 附件上传路径入库、文件落盘别把文件塞进数据库合同附件模块最容易做坏。有人把文件转 base64 直接塞数据库字段有人把附件表和合同表做无上限的级联。这两种我都不建议。常见做法是前端el-upload调后端接口后端把文件写到配置的磁盘目录或对象存储上数据库只存路径、大小、类型和上传人。PostMapping(/api/attachment/upload) public Result upload(RequestParam(file) MultipartFile file, RequestParam(contractId) Long contractId) { if (file.isEmpty()) { return Result.error(上传文件为空); } String ext FilenameUtils.getExtension(file.getOriginalFilename()); String relativePath /data/contract-files/ LocalDate.now() /; String newName UUID.randomUUID().toString().replace(-, ) . ext; file.transferTo(new File(relativePath newName)); AttachmentEntity entity new AttachmentEntity(); entity.setContractId(contractId); entity.setFileName(file.getOriginalFilename()); entity.setFilePath(relativePath newName); entity.setFileSize(file.getSize()); attachmentMapper.insert(entity); return Result.ok(entity.getId()); }三个细节值得注意文件名用 UUID 重命名避免中文文件名和非法字符带来乱码或路径穿越问题原文件名只存库按日期分目录后续迁移对象存储或做定时清理都方便单机内网用file.transferTo没问题如果系统要做集群部署建议直接接 MinIO 或对象存储否则文件会分散在不同机器上用户下载时经常 404。文件预览和下载就靠反向代理把/data/contract-files/映射成一个静态访问路径权限控制放在后端下载接口前端不直接拼 URL 访问磁盘路径。4.4 审批操作的前端交互待办列表和审批按钮待办列表不需要单独建待办表前端调一个接口后端根据当前登录人的角色去审批记录表找“当前节点待办人含该角色”的合同返回待审批列表。每个待办项显示合同名称、金额、提交人、当前节点。通过和驳回共用一个弹窗关键是要在后端返回错误时把后端提示原样展示比如“该合同已被其他审批人处理”而不是统一弹“操作失败”。审批按钮要加 loading 状态防止用户双击重复提交后端乐观锁虽然兜底但前端先防一层用户体验会好很多。5. 踩坑集中营合同管理系统开发里最常翻车的 5 个问题5.1 现象合同总金额导出 Excel 后出现 12345678.099999998财务对账时发现合同金额和发票金额对不上导出的 Excel 里出现一长串小数。原因是老系统里金额字段用的double二进制浮点表示导致精度漂移。解决方法是三层同时改数据库字段改成decimal(18,2)Java 实体改成BigDecimal导出 Excel 时统一用toPlainString()前端输入框限制两位小数。改完这三处金额相关的对账问题基本绝迹。5.2 现象同一份合同被两个领导同时审批通过两个审批人同时打开同一份待办先后点了通过数据库里出现两条通过流水合同状态被覆盖成最后一次更新。原因是先查后改的普通 update 没有版本约束。解决方法是加乐观锁更新语句带上WHERE status 1 AND version ?第二个请求更新行数为 0 就直接拒绝并提示刷新。如果项目里已经有一堆存量代码不想改实体注解也可以在 update SQL 里手动带版本条件效果一样。5.3 现象误删合同后附件数据还在但合同找不回来业务人员删错了一份合同管理员发现后无处恢复。原因是主表用了物理删除附件表还在但主表记录没了关联关系断裂。解决方法是所有核心表统一逻辑删除deleted字段配合 MyBatis-Plus 的TableLogic查询自动过滤。合同这类单据应该只有“作废”或“归档”不存在真正意义上的删除后端接口只提供状态变更不提供删除接口。5.4 现象并发创建合同时两个编号重复用户提示保存失败两个用户同时提交合同一个成功一个报错报错信息还是晦涩的唯一索引冲突。原因是后台用max(id) 1或先查库判重生成编号并发下必然失效。解决方法是拆出发号器表用一行记录的原子自增生成序号主表的唯一索引只做兜底。编号格式前期想清楚后面要改编号规则会牵动所有历史数据的关联。5.5 现象分页列表偶尔只有 7 条数据总数和实际不符合同列表每页设置 10 条有时候只显示 7、8 条翻页后数据重复。原因是主表 join 附件表后一条合同带多个附件被展开成多行分页在 join 之后执行行数被放大。解决方法是列表查询不 join 附件表附件数用子查询统计或者在 Java 层先查主表分页再批量查附件数并组装。子查询在数据量小时性能足够数据量大了以后可以改成冗余字段attachment_count定时更新。6. 进阶到期提醒和合同全文检索让系统从台账变成管理工具6.1 到期合同提醒一个定时任务加一个配置开关合同系统只做到“能存能查”价值有限到期提醒才是真正能减少损失的功能。用 Spring 的Scheduled每天固定时间扫一次即将到期的合同推送给业务负责人Component public class ContractExpireReminder { Scheduled(cron 0 0 9 * * ?) public void scanExpiringContracts() { LocalDate start LocalDate.now(); ListContractEntity list contractMapper.selectExpiring(start, start.plusDays(reminderDays)); // 调用企业微信机器人或内部消息接口推送 } }提醒天数reminderDays放到application.yml里配置不要写死。再配一个开关contract.reminder.enabled联调环境关掉避免测试数据天天推送打扰人。线上跑起来后第一周先观察有没有漏推和重复推推送任务要做幂等按“合同 ID 提醒批次日期”去重。6.2 附件内容检索用 Tika 抽文本别急着上 Elasticsearch合同的价值藏在附件里。几十万份合同之前不建议直接上 Elasticsearch用 Apache Tika 把 PDF、Word 文本抽取出来存进数据库就够了public String extractText(File file) throws TikaException, IOException { try (Tika tika new Tika()) { return tika.parseToString(file); } }抽取结果存到附件表的文本字段里后端查询用模糊匹配就可以覆盖“按付款条件找历史合同”这类需求。要注意一个边界扫描版的 PDF 本质是图片Tika 抽不出文字需要 OCR 引擎这是另一个成本立项前先确认合同文件是电子签章导出还是纸质扫描。我现在的习惯是每个合同上传后异步抽取一次全文存字段某天要模糊查历史时直接能用等数据量真到几十万再考虑接入全文检索服务。这套路数帮我省了不少后期返工希望帮到你。本文还有配套的精品资源点击获取