ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

合同管理系统开发:从状态机设计到到期提醒实现

合同管理系统开发:从状态机设计到到期提醒实现 1. 为什么我要在开发计划里专门排一天做合同管理先交代一下背景。我在做一整套企业管理系统的开发练习规划到第9天时轮到了合同管理系统。说实话一开始我也觉得合同管理不过就是个增删改查无非是比普通的CRUD多了几个字段合同编号、甲方乙方、金额、签署日期、到期日期然后到时候提醒一下续签。真做下来才发现这个模块的水远比想象中深。合同管理系统contract-management在企业内部通常不是独立存在的它要跟客户信息、项目立项、财务开票、法务审核、印章使用这些流程串在一起。如果只是做一个录入台账的工具那没有太大价值真正有价值的做法是把合同这个对象从起草、审批、签署、履约、变更、续签、归档做成一整条生命周期管理链路。在我这次开发计划里第9天安排这个主题核心目标有三个搞清楚合同数据的核心建模方式哪些字段是必须的哪些是业务扩展出来的把合同状态流转做成一套清晰且可控的状态机避免业务人员乱改数据把到期提醒和合同文件管理这两个高频需求做成真正可用的功能而不是摆设这篇文章就把我当天的完整开发过程梳理一遍从需求拆解、表结构设计、核心接口实现到踩过的坑全部摊开来讲希望能给正在做类似业务系统的同学一点参考。2. 需求拆解合同管理系统到底在管什么2.1 一个合同对象的核心要素我习惯先列问题清单再决定做哪些功能。当时我给自己提了几个问题公司里谁会用到合同管理系统销售、法务、财务、高层管理者角色不同关注点完全不同合同的状态有哪些草稿、审批中、已签署、履行中、已到期、已作废这些状态之间怎么流转到期提醒怎么做最合理是提前30天提醒一次还是分级提醒合同文件放哪里是存本地磁盘、对象存储还是数据库BLOB这些问题想清楚之后功能范围就出来了。我最终确定的核心功能模块有五个合同台账所有合同的统一列表支持多条件筛选和关键词搜索合同审批提交后走简单的审批流一级或多级审批通过才能盖章签署到期管理每日定时扫描合同到期时间生成提醒任务文件管理支持合同扫描件、附件上传下载基础数据维护合同类型、币种、供应商/客户信息、部门信息我刻意没有做电子签章对接因为那涉及第三方服务而且大概率要收费。但接口设计上我预留了扩展位后续要接入电子签章平台时不会伤筋动骨。2.2 用户角色与权限边界合同管理系统最容易被忽略的是角色视角的差异。销售想看的是我的合同和即将到期的合同财务想看的是合同金额和回款计划法务想看的是审批流程走到哪了老板想看的是这个季度合同总额是多少。所以权限体系我设计了四级角色主要权限数据范围普通用户新增合同、提交审批、查看自己创建的合同仅本人数据部门经理审批、查看本部门合同部门数据法务/财务审批、查看全量合同、下载文件全部数据系统管理员全部功能、基础数据维护、权限配置全部数据这个表格看起来简单但实现的时候涉及一个关键选择数据权限是在SQL层面处理还是在应用层处理我选择在SQL层面统一添加数据范围条件避免应用层拼数据导致越权。这个后面讲代码时会具体说。3. 技术选型这套合同管理系统我为什么这样搭3.1 选型思路与对比因为我做的是企业管理系统系列开发第9天这个模块要跟其他模块共用一套技术栈不能搞特殊。综合考量后选择了以下组合后端Java 17 Spring Boot 3.x前端Vue 3 Element Plus数据库MySQL 8.0ORMMyBatis-Plus鉴权Spring Security JWT定时任务Spring Schedule文件存储本地磁盘预留OSS扩展接口后端技术栈是市面上最常见的组合招人容易资料多踩坑了也好搜解决方案。如果你是用Node.js或者Python做后端核心逻辑大同小异关键在业务建模思路不用纠结具体语言。3.2 为什么不用工作流引擎合同审批这个场景很多人第一反应是引入Flowable或Activiti这样的工作流引擎。我的看法是合同审批在一个中小型系统里往往只是一条固定的审批链比如销售提交 - 部门经理审批 - 法务审批 - 财务会签没有复杂的会签、或签、条件分支这时候引入重量级工作流引擎是给自己找麻烦。工作流引擎的好处是流程可视化、可动态调整但代价是要维护流程定义、部署流程、管理流程实例学习成本和运维成本都不低。我最终选择了在代码里硬编码一条审批链配合一张审批记录表来保存每一步的操作历史。等业务真的复杂到需要流程自定义时再迁移到工作流引擎也不迟。这里有个经验做管理系统功能越贴合实际越好不要为了技术炫技而引入重型组件。能用一张表说明白的流程就不要用一张流程图来画。4. 数据库设计合同表、状态机和金额字段的细节4.1 合同主表设计合同管理系统最核心的就是合同主表我把它命名为contract_info。字段设计上我没有把所有信息都塞进一张表而是拆成了主表、审批记录表、附件表各司其职。CREATE TABLE contract_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, contract_no VARCHAR(64) NOT NULL COMMENT 合同编号, contract_name VARCHAR(200) NOT NULL COMMENT 合同名称, contract_type VARCHAR(32) NOT NULL COMMENT 合同类型采购/销售/框架/其他, party_a VARCHAR(200) NOT NULL COMMENT 甲方我方, party_b VARCHAR(200) NOT NULL COMMENT 乙方对方, contract_amount DECIMAL(18, 2) NOT NULL DEFAULT 0.00 COMMENT 合同金额, currency_code VARCHAR(8) NOT NULL DEFAULT CNY COMMENT 币种, sign_date DATE COMMENT 签署日期, start_date DATE NOT NULL COMMENT 生效日期, end_date DATE NOT NULL COMMENT 到期日期, status VARCHAR(20) NOT NULL DEFAULT DRAFT COMMENT 合同状态, creator_id BIGINT NOT NULL COMMENT 创建人ID, dept_id BIGINT NOT NULL COMMENT 所属部门ID, file_path VARCHAR(500) COMMENT 合同文件路径, remark VARCHAR(500) COMMENT 备注, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, KEY idx_status (status), KEY idx_end_date (end_date), KEY idx_creator (creator_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT合同信息表;有几个设计点我想单独展开说**第一合同金额必须用DECIMAL而不是FLOAT或DOUBLE。**这是财务系统的铁律。浮点数在二进制里是无理数0.1 0.2 可能等于 0.30000000000000004做金额计算时一旦出现这种偏差对账就对不上。DECIMAL(18, 2)可以精确表示小数点后两位18是总位数足够容纳亿级金额假设你的合同总额不超过1亿元如果可能更大就改成20或22位。**第二生效日期和签署日期是两个字段。**很多初学者会把这两个混成一个。实际上合同可能先签了字但生效日期却是未来的某个节点比如本合同自2025年1月1日起生效。这两个日期在业务上语义不同到期提醒应该用end_date不签日期则可以为空。**第三状态字段为什么用VARCHAR而不是INT。**有人习惯用0、1、2表示不同的状态但可读性太差了查数据时看到status3还得去翻文档确认是什么状态。我直接用英文枚举字符串虽然存储上多占一点空间但排查问题时的体验好太多。4.2 合同状态机的流转设计状态机是合同管理系统的灵魂。我当时梳理出六种状态DRAFT草稿刚创建还没有提交审批PENDING审批中已提交等待审批EFFECTIVE履行中已审批通过并签署合同生效EXPIRED已到期超过end_date且没有续签TERMINATED已终止合同提前终止VOID已作废审批驳回或误创建状态流转的规则我在代码里做了严格限制不能随意跳转比如DRAFT 只能转为 PENDING 或 VOIDPENDING 审批通过后转为 EFFECTIVE驳回则回到 DRAFTEFFECTIVE 到期后自动转为 EXPIRED也可以手动终止变成 TERMINATEDEXPIRED、TERMINATED、VOID 是终态不能再做修改为什么这么严格因为合同数据的准确性直接影响财务和法律风险。如果业务人员可以把一份生效中的合同直接改成已作废那审核的意义就不存在了。我在状态变更的地方统一加了一个transitionStatus方法所有状态流转都必须经过这个方法校验非法流转直接抛异常。这个设计在前期会多花一点时间但后期维护成本会大大降低。你可以理解为给系统装了一套交通规则红灯停绿灯行不按规则走就会被拦下来。4.3 审批记录表设计审批记录表是另一个容易被忽略的点。很多人实现审批功能时只在主表上更新一个审批人和审批状态字段这会导致一个问题无法追溯合同在每一步由谁处理过、处理结果是什么、留下了什么意见。CREATE TABLE contract_approval_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, contract_id BIGINT NOT NULL COMMENT 合同ID, approver_id BIGINT NOT NULL COMMENT 审批人ID, approver_name VARCHAR(50) NOT NULL COMMENT 审批人姓名, approval_order INT NOT NULL COMMENT 审批顺序, approval_action VARCHAR(20) NOT NULL COMMENT 动作SUBMIT/APPROVE/REJECT, approval_comment VARCHAR(500) COMMENT 审批意见, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_contract_id (contract_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT合同审批记录表;这张表不仅记录了每一步的操作还可以在合同详情页做审批历史时间线用户能看到这份合同从提交审批到最终通过的全过程。这个功能在真实业务里很受欢迎因为它能减少大量的口头沟通和邮件往来。5. 核心功能代码实现审批链、到期提醒与文件上传5.1 审批链的硬编码实现我把审批链定义成一个列表考虑到公司规模和合同类型设计了两种审批链规则普通合同销售提交 - 部门经理审批 - 法务审批大额合同金额≥50万元销售提交 - 部门经理审批 - 法务审批 - 财务审批 - 总经理审批代码层面我定义了一个审批节点枚举public enum ApprovalNode { MANAGER(部门经理, 1), LEGAL(法务, 2), FINANCE(财务, 3), GENERAL_MANAGER(总经理, 4); private final String desc; private final int order; // getter... }然后封装一个审批服务类核心方法是approve(contractId, userId, comment)Transactional public void approve(Long contractId, Long userId, String comment) { ContractInfo contract contractMapper.selectById(contractId); if (contract null) { throw new BusinessException(合同不存在); } if (!StatusEnum.PENDING.getCode().equals(contract.getStatus())) { throw new BusinessException(当前合同不在审批中不能审批); } ListApprovalRecord records approvalRecordMapper.selectByContractId(contractId); ApprovalNode currentNode getCurrentNode(records); // 校验当前用户是否是当前节点的审批人省略角色校验细节 // 写入审批记录 ApprovalRecord record buildApprovalRecord(contractId, userId, comment, currentNode, APPROVE); approvalRecordMapper.insert(record); // 判断审批是否全部通过 ListApprovalNode chain getApprovalChain(contract.getContractAmount()); if (records.size() 1 chain.size()) { // 审批链走完合同生效 contract.setStatus(StatusEnum.EFFECTIVE.getCode()); contractMapper.updateById(contract); } }这段代码的核心思想是通过已有审批记录条数判断当前节点而不是在主表上存一个当前审批人的字段。后者的坏处是并发场景下两个人同时审批时状态可能被互相覆盖前者的好处是审批记录天然就是幂等的判断依据。为了处理驳回场景我在驳回时会将合同状态置回DRAFT并把已产生的审批记录逻辑删除。这样用户修改后可以重新提交审批链重新开始。5.2 到期提醒定时任务与提醒记录表合同到期提醒是合同管理系统里用户感知最强的功能。合同到期没发现可能造成自动续约或者权益损失所以这个功能必须做到该提醒的一定要提醒到不该提醒的别瞎提醒。我实现的逻辑是每天凌晨执行一次定时任务扫描所有statusEFFECTIVE的合同把未来30天内到期和已经过期未处理的合同找出来。每个合同生成一条提醒记录Component public class ContractExpireRemindTask { // 每天凌晨1点执行 Scheduled(cron 0 0 1 * * ?) public void scanExpiringContracts() { LocalDate today LocalDate.now(); LocalDate remindDeadline today.plusDays(30); ListContractInfo expiringContracts contractMapper.selectList( new LambdaQueryWrapperContractInfo() .eq(ContractInfo::getStatus, EFFECTIVE) .between(ContractInfo::getEndDate, today, remindDeadline) ); for (ContractInfo contract : expiringContracts) { if (!remindRecordMapper.exists(contract.getId(), today)) { remindRecordMapper.insert(new RemindRecord(contract.getId(), 合同[ contract.getContractNo() ]将在 contract.getEndDate() 到期请及时处理续签或终止。)); } } } }这里有个小小的防重复设计提醒记录表以contract_id remind_date做唯一索引同一份合同同一天不会被反复提醒。如果合同还有15天到期今天提醒一次明天任务再跑一遍也不会生成重复记录。至于提醒的触达方式如果系统有站内信模块可以发站内信有邮件服务可以发邮件有企业微信或钉钉机器人就发Webhook通知。我这套系统做了站内信和企业微信机器人两种企业微信机器人就是往群里推一条文本消息想实现的话二三十行代码就能搞定。5.3 文件上传目录隔离与命名规则合同附件管理最容易出问题的点是文件命名和存储路径。如果用户上传的文件名就叫新建文档.pdf几十个合同传完最终文件列表根本没法看。更麻烦的是同名文件互相覆盖。我的做法是按月份分目录/data/contract-files/2025-07/文件名用合同编号_时间戳_原始文件名拼接HT20250701001_1720252800000_新建文档.pdf文件路径存数据库服务器磁盘上不保留原始目录结构这样的好处有三个目录按月隔离方便归档清理合同编号保证文件与合同强关联时间戳保证同一个合同上传多个版本时不会互相覆盖。Java端的实现逻辑大致是public String uploadFile(MultipartFile file, String contractNo) { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String dateDir LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy-MM)); String filename contractNo _ System.currentTimeMillis() ext; Path dir Paths.get(UPLOAD_BASE_DIR, dateDir); Files.createDirectories(dir); file.transferTo(dir.resolve(filename).toFile()); return dateDir / filename; }存储路径存数据库下载时再拼完整路径返回。等后续接入OSS或者MinIO时只需要把uploadFile方法里的存储介质换成对象存储SDK接口签名不用动。6. 踩坑记录排查链路上的关键问题6.1 金额比较时NPE怎么排查出来的写合同审批逻辑时我要判断一个大额合同是否需要启用更长的审批链。代码里有一段if (contract.getContractAmount().compareTo(new BigDecimal(500000)) 0) { // 走大额审批链 }结果测试时发现有一份没有填金额的草稿合同在提交审批时直接报了NullPointerException。排查链路是这样的第一步看异常堆栈定位到价格比较那行确认是contract.getContractAmount()返回了null。第二步追查数据来源。发现是前端表单里金额输入框允许留空用户没填就点提交后端DTO里对应的BigDecimal字段直接是null没有做默认值处理。第三步修复方案。在提交审批的接口里加一个校验合同金额必须大于0才能提交。同时把所有涉及金额比较的地方都判空。这个bug看着小但如果在生产环境出现会直接导致审批流程中断。这个坑也提醒我Java后端处理金额字段所有入参都要先做非空校验再谈业务逻辑。别指望前端帮你挡掉所有非法输入。6.2 状态流转并发问题两份审批同时提交测试阶段发现一个有趣的问题合同的部门经理和法务几乎同时点了同意结果合同状态变成了EFFECTIVE但审批记录只有一条。分析原因两个请求同时进入approve方法都查到了statusPENDING都走完了校验都往审批记录表插入数据然后都试图把主表更新为EFFECTIVE。解决方案我在上面已经提到过就是在审批记录表加唯一约束保证同一审批节点只能有一条有效记录。具体做法是加一个(contract_id, approval_order, is_deleted)的唯一索引is_deleted为0表示有效记录。插入时如果有相同顺序的记录已存在数据库会拒绝插入其中一条请求就会报错事务回滚。这个改动之后我在测试环境用JMeter模拟了20个并发审批请求最终只有一条成功其他全部抛出异常状态没有被破坏。6.3 定时任务在生产环境重复执行到期提醒定时任务部署时遇到一个典型问题应用为了高可用部署了两个实例结果每天凌晨两个实例同时扫描生成了两批提醒记录。我的解决方案有两层。第一层是数据库层面做唯一索引兜底前面已经说了。第二层是引入分布式锁确保同一时刻只有一个实例在跑任务。用Redis实现很简单String lockKey contract:expire:remind:lock; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofMinutes(10)); if (Boolean.TRUE.equals(locked)) { try { doScanAndRemind(); } finally { redisTemplate.delete(lockKey); } }如果项目里没有Redis也可以用数据库里的乐观锁字段或SELECT ... FOR UPDATE来实现原理都一样保证关键操作的互斥性。7. 项目结构组织第9天代码文件分布最后晒一下我的项目结构方便你对照理解。这个系统的代码结构基本按照控制器 - 服务 - 数据访问 三层来划分但针对合同管理的特殊性做了一些调整contract-management/ ├── src/main/java/com/example/contract/ │ ├── controller/ │ │ ├── ContractController.java # 合同增删改查接口 │ │ ├── ApprovalController.java # 审批相关接口 │ │ └── FileController.java # 文件上传下载接口 │ ├── service/ │ │ ├── ContractService.java # 核心业务逻辑 │ │ ├── ApprovalService.java # 审批链逻辑 │ │ ├── ContractExpireRemindTask.java # 到期提醒定时任务 │ │ └── FileStorageService.java # 文件存储服务 │ ├── entity/ │ │ ├── ContractInfo.java │ │ ├── ApprovalRecord.java │ │ └── RemindRecord.java │ ├── enums/ │ │ ├── ContractStatusEnum.java # 合同状态枚举 │ │ └── ApprovalNodeEnum.java # 审批节点枚举 │ └── mapper/ │ ├── ContractInfoMapper.java │ ├── ApprovalRecordMapper.java │ └── RemindRecordMapper.java └── src/main/resources/ ├── mapper/ │ ├── ContractInfoMapper.xml │ └── ApprovalRecordMapper.xml └── application.ymlservices目录下最关键的是ApprovalService和ContractExpireRemindTask一个管流程一个管时间其余都是常规操作。如果你打算在小团队协作中使用这种系统建议再补一个简单的收件箱页面登录后显示待我审批的合同数量、我创建的合同即将到期的数量、我关注合同的续签提醒。一个小通知入口就能让系统的价值感提升一大截。8. 后续扩展方向从能用走向好用把第9天的合同管理系统基础功能跑通之后我梳理了几个可以继续深化的方向电子签章集成。目前合同签署还是线上审批、线下盖章的模式但电子签章已经是企业数字化的标配。预留第三方接口时主要考虑两件事签署回调的验签逻辑以及签署状态与内部合同状态的双向同步。合同模板管理。很多公司的合同是根据模板生成的比如销售合同、采购合同字段基本固定只是每次填入不同的客户名、金额、日期。做一个模板引擎把合同内容用变量占位用户选模板填表单后自动生成合同文本能大幅提升效率。合同履约节点管理。很多合同不是签完就完了里面有付款计划、交货节点、验收条件。把合同的履约节点拆出来每个节点关联日期和负责人到期提醒会更精细。这也是合同管理系统从台账工具进化为履约管理的关键一步。多币种与汇率处理。如果你所在的公司有涉外业务合同金额可能涉及USD、EUR等币种。不建议直接改主表金额字段更好的做法是保存原币种金额加一个汇率表报表展示时统一换算成本位币。汇率每日更新换算时取合同当前生效期间的汇率逻辑要谨慎设计。这几块我计划放在后续的开发计划里逐一实现。第9天能做到基础台账、审批、提醒、文件管理全部跑通对一套内部管理系统来说已经够用了剩下的都是锦上添花。做项目也是一样先把主干立起来再考虑枝叶不然容易陷入无休止的需求旋涡里。
返回列表