ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot+Vue合同管理系统源码解析:从审批流到到期提醒

Spring Boot+Vue合同管理系统源码解析:从审批流到到期提醒

简介:一套面向企业合同管理场景的完整源码资源,适合需要搭建或二次开发现代化合同管理系统的开发人员及企业信息化负责人。资源以合同信息管理模块为核心,覆盖合同录入、审核、签署、履行、续签终止等环节,同时提供合同扫描电子化、设备信息管理及年度统计等功能,可帮助组织提升合同处理效率、降低纸质文件风险。压缩包大小47.99MB,共654个文件,其中以C#源码(92个cs)、程序集(103个dll)、可执行文件(26个exe)为主,另含resources/resx界面资源、png/bmp图标、xml/config配置及数据库文件等,类型齐全,便于直接编译运行或按需改造。源码模块划分清晰,从数据库接口到UI界面均有对应实现,读者可借此理解系统后台逻辑、合同扫描图像处理及报表模块的编写思路。目前已有927人学习下载,适合希望以完整项目为参考快速落地合同管理系统的中高级开发者。

1. 合同管理系统源码:先解决“到期没人管”而不是“录入”

拿到这套合同管理系统源码时,我第一反应是它把重心放在了“审批流”和“到期提醒”上,而不是停留在合同登记台账。很多团队的合同管理其实不是缺录入工具,而是缺一条能自动流转的链路:谁起草、谁审批、到期谁负责续签。这套源码正好覆盖了从合同起草、审批、归档、提醒到台账统计的完整闭环。后端是Spring Boot + MyBatis-Plus,前端是Vue,数据库MySQL,代码结构清楚,拿来就能跑。

适合谁看?一类是正在做企业内部系统二次开发的开发者,想找一个业务完整、可改可扩展的样例;另一类是刚接触前后端分离项目的初级工程师,想看懂一个真实业务系统是怎么组织的。这个项目没有复杂的分布式架构,也没有花哨的微服务,它做的就是一件事:把合同管理这条业务链路用最稳妥的方式落地。学习它能快速搞懂状态机、定时任务、聚合查询这些在企业系统里天天用的东西。

2. 整体架构与项目结构:从审批流到台账的数据闭环

2.1 技术选型:为什么是 Spring Boot + Vue + MySQL

这套源码采用前后端分离方案,后端用Spring Boot系列框架,配合MyBatis-Plus做数据持久层,前端用Vue框架配合Element组件库,数据库选用MySQL,认证用的是JWT。我跑完后最大的感受是:选型没有追新,但每个组件都站在了企业级应用最不容易出错的位置上。

Spring Boot负责提供REST API,内置Tomcat,打包成jar就能跑;MyBatis-Plus把单表CRUD、分页查询、条件构造器都封装好了,省掉大量重复的XML配置;Vue负责页面交互,通过axios调用接口;MySQL存储业务数据,InnoDB引擎保证事务。JWT做无状态登录,避免了Session共享问题。这套组合对合同管理这种中后台系统来说,属于“稳妥牌”经典搭配。

为什么不用更重的微服务架构?合同系统通常内部使用,并发量不大,单体应用部署简单、排错直接,后端代码都在一个工程里,二次开发时改一处就能全局生效。如果需要横向扩展,后端接口已经做到无状态,后续加一层Nginx负载均衡就能接住更大压力。

2.2 目录结构:一个可移植的工程骨架

后端工程目录清晰,按功能模块分包而不是按技术分层分包。这种组织方式在业务系统里非常实用:你要改合同相关的功能,直接进contract包,不用在controller、service、mapper之间来回跳。

contract-system/ ├── backend/ │ ├── src/main/java/com/company/contract/ │ │ ├── controller/ # REST接口层 │ │ │ ├── ContractController.java │ │ │ ├── ApproveController.java │ │ │ └── UserController.java │ │ ├── service/ # 业务逻辑层 │ │ │ ├── ContractService.java │ │ │ ├── ApproveService.java │ │ │ └── RemindService.java │ │ ├── mapper/ # MyBatis-Plus数据访问层 │ │ │ ├── ContractMapper.java │ │ │ └── ApproveRecordMapper.java │ │ ├── entity/ # 数据库实体 │ │ │ ├── Contract.java │ │ │ └── ApproveRecord.java │ │ ├── common/ # 通用返回结果、异常处理 │ │ ├── config/ # 跨域、拦截器、定时任务配置 │ │ └── ContractApplication.java │ └── src/main/resources/ │ ├── application.yml │ └── mapper/ # 复杂SQL的XML文件 ├── frontend/ │ ├── src/ │ │ ├── views/ │ │ │ ├── ContractList.vue │ │ │ ├── ContractEdit.vue │ │ │ └── ApproveList.vue │ │ ├── api/ # 接口封装 │ │ ├── router/ # 前端路由 │ │ └── store/ # 登录状态管理 │ └── package.json └── sql/ └── init.sql # 建库建表及初始化数据

前端工程按页面维度划分,ContractList负责台账展示,ContractEdit负责新建和编辑,ApproveList负责审批待办。api目录里统一封装了axios调用,router控制页面跳转,store保存登录用户信息。这种结构对想定制功能的人来说非常友好,找文件的时间成本很低。

2.3 数据模型设计:合同主表、审批记录表、提醒任务表

核心数据模型围绕三个表展开:合同主表保存合同基础信息,审批记录表保存每一步流转历史,提醒任务表保存到期预警状态。我一般把这三张表比作“主数据 + 流水 + 任务”的铁三角。

-- 合同主表 CREATE TABLE `contract` ( `id` bigint NOT NULL AUTO_INCREMENT, `contract_no` varchar(64) NOT NULL COMMENT '合同编号', `title` varchar(255) NOT NULL COMMENT '合同标题', `party_a` varchar(128) DEFAULT NULL COMMENT '甲方名称', `party_b` varchar(128) DEFAULT NULL COMMENT '乙方名称', `amount` decimal(12,2) DEFAULT NULL COMMENT '合同金额', `status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT '状态: DRAFT/APPROVING/APPROVED/REJECTED/ARCHIVED', `sign_date` date DEFAULT NULL COMMENT '签订日期', `expire_date` date DEFAULT NULL COMMENT '到期日期', `owner_id` bigint NOT NULL COMMENT '负责人ID', `file_url` varchar(500) DEFAULT NULL COMMENT '附件地址', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status` (`status`), KEY `idx_expire_date` (`expire_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

status字段存储当前合同状态,从DRAFT(草稿)开始,提交后进入APPROVING(审批中),通过后变为APPROVED(已生效),到期或履行完毕后归档为ARCHIVED(已归档)。idx_status和idx_expire_date是高频查询索引,清单页按状态筛选,提醒任务按到期日扫表,这两个索引能避免全表扫描。

-- 审批记录表 CREATE TABLE `approve_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `contract_id` bigint NOT NULL COMMENT '合同ID', `action` varchar(20) NOT NULL COMMENT '动作: SUBMIT/APPROVE/REJECT/ARCHIVE', `from_status` varchar(20) DEFAULT NULL, `to_status` varchar(20) NOT NULL, `operator_id` bigint NOT NULL COMMENT '操作人ID', `comment` varchar(255) DEFAULT NULL COMMENT '审批意见', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_contract_id` (`contract_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

审批记录只追加、不修改,完整保留合同生命周期里的每一次操作。from_status和to_status两个字段记录了状态跳转,配合操作人ID,方便审计追溯。提醒任务表则用来去重,标记某个合同在某一天是否已经发送过临近到期通知,避免重复打扰。

3. 核心模块拆解:一个合同从起草到归档的完整链路

3.1 合同起草与保存:后端校验 + 附件上传

合同起草是整个流程的入口。前端ContractEdit.vue收集表单数据,附件上传走独立接口,后端保存时做必填校验、编号生成和状态初始化。这里隐藏一个细节:编号生成要保证唯一,常见做法是“前缀 + 年月日 + 四位随机数”,但更好的做法是直接查库取当天的最大序号,避免并发下撞号。

@PostMapping("/contract") public Result save(@RequestBody ContractDTO dto) { if (StringUtils.isBlank(dto.getTitle())) { return Result.error("合同标题不能为空"); } if (dto.getExpireDate() == null) { return Result.error("到期日期不能为空"); } Contract contract = new Contract(); BeanUtils.copyProperties(dto, contract); contract.setContractNo(generateContractNo()); contract.setStatus("DRAFT"); contract.setOwnerId(LoginUser.getCurrentUserId()); contractService.save(contract); return Result.ok(contract.getId()); }

这里有几个容易忽略的点:BeanUtils.copyProperties把DTO的属性拷贝到实体,省去手动set,但要注意日期类型格式必须一致;状态置为DRAFT是硬编码,如果后续要扩展“待提交”等中间态,可以把状态初始化放到配置里。返回结果只返回了合同ID,前端拿到后可以继续走上传附件接口。

附件上传单独做,原因是文件体量大,不适合和表单数据一起走JSON。常见做法是后端接收MultipartFile,保存到本地或OSS,然后把file_url写回合同表。如果不上传文件,合同也能保存,但会少一个重要信息源。

3.2 审批流引擎:状态机的实现

审批流是这个系统最值得看的模块。实现思路是:先用状态枚举固定住合法跳转,再通过Service层控制流转动作。这种实现比用工作流引擎轻得多,适合内部合同审批这种固定流程。

public enum ContractStatus { DRAFT, // 草稿 APPROVING, // 审批中 APPROVED, // 已通过 REJECTED, // 已驳回 ARCHIVED; // 已归档 public static boolean canTransition(ContractStatus from, ContractStatus to) { switch (from) { case DRAFT: return to == APPROVING || to == REJECTED; case APPROVING: return to == APPROVED || to == REJECTED; case APPROVED: return to == ARCHIVED; default: return false; } } }

调用审批接口时,第一步先校验当前状态是否允许跳转。这一步很多人会漏,导致前端传一个非法状态直接把数据写乱了。

@PostMapping("/approve/{contractId}") public Result approve(@PathVariable Long contractId, @RequestBody ApproveRequest req) { Contract contract = contractService.getById(contractId); ContractStatus from = ContractStatus.valueOf(contract.getStatus()); ContractStatus to = ContractStatus.valueOf(req.getTargetStatus()); if (!ContractStatus.canTransition(from, to)) { return Result.error("非法状态流转: " + from + " -> " + to); } ApproveRecord record = new ApproveRecord(); record.setContractId(contractId); record.setAction(req.getAction()); record.setFromStatus(from.name()); record.setToStatus(to.name()); record.setOperatorId(LoginUser.getCurrentUserId()); record.setComment(req.getComment()); approveService.save(record); contract.setStatus(to.name()); contractService.updateById(contract); return Result.ok(); }

这段代码要配合数据库事务使用,在Service方法上加@Transactional,保证审批记录写入和合同状态更新要么同时成功,要么同时失败。我遇到过只更新了状态却没写记录的翻车情况,后来统一把“记录流水”和“更新主表”放在一个事务方法内,问题就消失了。

3.3 到期提醒:定时任务怎么扫表

到期提醒使用Spring自带的 @Scheduled 定时任务,每天早上8点扫描一次合同表,找出未来30天内到期且未归档、未提醒的合同,然后给负责人发送通知。这个模块不难,但定时任务有几个坑值得单独说。

@Scheduled(cron = "0 0 8 * * ?") public void remindExpiringContracts() { LocalDate today = LocalDate.now(); LocalDate expireEnd = today.plusDays(30); List<Contract> contracts = contractMapper.findExpiring( today, expireEnd, "APPROVED"); for (Contract contract : contracts) { boolean exists = remindMapper.exists(contract.getId(), today); if (!exists) { sendRemindMessage(contract); remindMapper.insert(contract.getId(), today); } } }

cron表达式0 0 8 * * ?表示每天8点整触发一次。查询条件里过滤了状态是APPROVED的合同,因为草稿和审批中的合同还没生效,不需要提醒。remind表的存在是为了防止同一天内重复发送,也防止重启服务后扫表又扫到前一天处理过的数据。

这里有个需要注意的场景:如果定时任务跑挂了,比如数据库连接池耗尽,那么当天的提醒会全部丢失。常见做法是加一个简单的执行日志表,任务启动时先查上次执行时间,如果发现超过一天没执行就触发补偿逻辑。源码里没有做补偿,但预留了提醒表结构,二次开发时可以直接在此基础上加。

3.4 合同台账与搜索:聚合查询的SQL写法

台账列表页需要展示分页数据、状态统计、金额汇总。这些查询如果用Java代码遍历内存列表,效率低且不优雅。源码里直接用聚合SQL把统计结果从数据库算出来,接口响应速度在万级数据量下依然能稳定在100毫秒以内。

-- 按状态统计合同数量 SELECT status, COUNT(*) AS count FROM contract WHERE owner_id = #{ownerId} GROUP BY status; -- 合同金额汇总 SELECT SUM(amount) AS total_amount, COUNT(*) AS total_count FROM contract WHERE expire_date BETWEEN #{start} AND #{end} AND status = 'APPROVED';

第一段SQL用于首页状态卡片,展示草稿几份、审批中几份、已通过几份。第二段SQL用于统计未来某个时间段内到期合同的总金额,帮助法务同事预估续签工作量。注意SUM函数在amount为NULL时返回NULL,所以前端读取total_amount时要加判空。

搜索功能用的是MyBatis-Plus的LambdaQueryWrapper,条件拼接放在Service层。常见做法是:标题用like模糊匹配,合同编号用eq精确匹配,日期范围用ge和le过滤,状态用in支持多选。前端传什么参数,后端就拼什么条件,没有参数就查全部,这样列表页的筛选交互很灵活。

4. 常见问题排查:从启动到上线,我踩过的坑

4.1 环境准备与启动步骤(别漏这三个配置)

先把项目跑起来,需要的环境是JDK 8或11、MySQL 5.7或8.0、Node.js 14以上。后端直接执行Application启动类,前端在frontend目录下运行npm install然后npm run dev。但启动前有三个配置必须确认,否则大概率翻车。

第一,MySQL连接串里的时区参数不能漏。在application.yml里,jdbc连接url要加上空字符时区配置,否则日期字段会差8小时,到期提醒的日期计算全乱。

spring: datasource: url: jdbc:mysql://localhost:3306/contract_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

第二,前端接口代理要指向后端端口。Vue开发服务器默认8080,后端接口也是8080,所以前端配置里要开启代理,把/home转发到后端地址。如果直接写死ip,调试时会非常痛苦。

// vue.config.js module.exports = { devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }

第三,数据库初始化需要手动执行sql/init.sql。源码不自带自动建库,建库语句里要选择合适的字符集,建议utf8mb4,这样中文全文搜索和特殊字符都能存。

4.2 高频问题清单:现象、原因、解决

以下5条是实际运行中高频出现的问题,每条都按现象、原因、解决来写,方便对照排查。

问题1:启动时端口被占用

现象:后端启动日志报Tomcat已经使用8080端口,应用起不来。原因:本地另一个进程占用了8080,或者上一次启动的服务没有停止。解决:改后端端口,或者找到占用进程杀掉。我一般先在application.yml里换成server.port=8081,快速验证问题,再回头清理旧进程。

问题2:数据库插入中文乱码

现象:合同标题和甲方名称存进去变成问号。原因:数据库连接串没指定characterEncoding,或者表字符集不是utf8mb4。解决:先改连接串,重启服务看是否正常;如果还乱码,把库和表都改成utf8mb4。执行SQL:ALTER TABLE contract CONVERT TO CHARACTER SET utf8mb4。

问题3:前端请求接口跨域

现象:浏览器控制台报CORS错误,请求发不出去。原因:前后端分离后,前端端口是3000,后端是8080,浏览器默认拦截跨源请求。解决:后端加一个全局CORS配置类,允许指定来源、请求头和认证信息。这是个很标准的过滤器配置,直接加到config包里即可。

问题4:审批通过后状态没有变

现象:在界面上点击通过,返回成功,但再次刷新后合同状态还是审批中。原因:大概率是事务没提交,或者状态更新的SQL条件带上了旧状态。解决:先看后端日志里有没有报错,再检查service方法上是否有@Transactional。如果更新语句用了UPDATE ... WHERE status = 'APPROVING',而并发场景下状态已经被其他操作改了,就会更新0行,前端却显示成功。这个坑很隐蔽,我在测试并发时遇到过,解决方法是更新前先查一次当前状态并在更新SQL里带上id主键。

问题5:定时任务完全不执行

现象:日志里看不到任何提醒输出。原因:启动类上漏了@EnableScheduling注解。Spring Boot不会自动开启定时任务,需要显式声明。解决:在Application类上加这个注解,重启服务。如果加了仍不执行,检查cron表达式是否写对,以及定时任务类是否被Spring扫描到。

5. 二次开发:把固定流程改成可配置流程

5.1 改造思路:用流程定义表替换硬编码状态机

这套源码的审批流是写死在Java枚举里的,内部流程不变时很好用,但如果不同部门有不同的审批路径,改代码就太麻烦了。我在二次开发时做过的最大改动,是把状态机从代码里挪到数据库表里。

改造方向是新增一张流程定义表,保存每个状态允许流转到哪些目标状态,以及每个流转需要什么角色。审批Service启动时把表数据加载到内存缓存,每次审批先查缓存中该状态允许的后续状态,再校验当前用户角色。

// 改造后的审批校验逻辑 List<FlowNode> nodes = flowConfigService.getByStatus(currentStatus); boolean pass = nodes.stream() .anyMatch(n -> n.getTargetStatus().equals(req.getTargetStatus()) && n.getRole().equals(LoginUser.getCurrentUser().getRole())); if (!pass) { return Result.error("当前角色不允许该操作"); }

配置表的好处是,业务方提出新流程时,只用数据库管理工具插入几条记录,不用发版重启。这个改造工作量不大,但能让系统从“死流程”变成“活流程”。同时,审批记录表新增了一个config_id字段,用来跟踪每次流转使用的是哪条配置,方便回溯。

5.2 验证方法:从用例到回归

改完流程配置后,验证不能只看单个接口通不通。我一般会准备一张测试清单,覆盖草稿提交、审批通过、驳回、归档、非法流转、超期提醒六个场景。重点验证非法流转:比如直接从草稿跳到归档必须被拦截,因为这类操作会破坏数据一致性。

另一个有效方法是做“配置驱动测试”:在测试库插入多条流程配置,用同一个接口跑不同角色、不同目标状态的组合,断言返回结果符合配置预期。我后来还加了一个小工具,每次上线前自动导出配置表和审批记录的比对结果,看有没有不一致的脏数据。

从那以后我每次调整状态机,都会强制走一遍“配置验证 + 事务边界检查 + 异常流程回归”,确认审批记录和状态更新始终在同一个事务里。这种习惯帮我躲过了好几次“数据错乱”的线上事故。希望这篇解析能帮你在跑通源码和二次开发时少踩几个坑。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进