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.0
- ORM:MyBatis-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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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表示不同的状态,但可读性太差了,查数据时看到status=3还得去翻文档确认是什么状态。我直接用英文枚举字符串,虽然存储上多占一点空间,但排查问题时的体验好太多。
4.2 合同状态机的流转设计
状态机是合同管理系统的灵魂。我当时梳理出六种状态:
- DRAFT(草稿):刚创建,还没有提交审批
- PENDING(审批中):已提交,等待审批
- EFFECTIVE(履行中):已审批通过并签署,合同生效
- EXPIRED(已到期):超过end_date且没有续签
- TERMINATED(已终止):合同提前终止
- VOID(已作废):审批驳回或误创建
状态流转的规则我在代码里做了严格限制,不能随意跳转,比如:
- DRAFT 只能转为 PENDING 或 VOID
- PENDING 审批通过后转为 EFFECTIVE,驳回则回到 DRAFT
- EFFECTIVE 到期后自动转为 EXPIRED,也可以手动终止变成 TERMINATED
- EXPIRED、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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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("当前合同不在审批中,不能审批"); } List<ApprovalRecord> records = approvalRecordMapper.selectByContractId(contractId); ApprovalNode currentNode = getCurrentNode(records); // 校验当前用户是否是当前节点的审批人(省略角色校验细节) // 写入审批记录 ApprovalRecord record = buildApprovalRecord(contractId, userId, comment, currentNode, "APPROVE"); approvalRecordMapper.insert(record); // 判断审批是否全部通过 List<ApprovalNode> chain = getApprovalChain(contract.getContractAmount()); if (records.size() + 1 >= chain.size()) { // 审批链走完,合同生效 contract.setStatus(StatusEnum.EFFECTIVE.getCode()); contractMapper.updateById(contract); } }这段代码的核心思想是:通过已有审批记录条数判断当前节点,而不是在主表上存一个"当前审批人"的字段。后者的坏处是并发场景下两个人同时审批时,状态可能被互相覆盖;前者的好处是审批记录天然就是幂等的判断依据。
为了处理驳回场景,我在驳回时会将合同状态置回DRAFT,并把已产生的审批记录逻辑删除。这样用户修改后可以重新提交,审批链重新开始。
5.2 到期提醒:定时任务与提醒记录表
合同到期提醒是合同管理系统里用户感知最强的功能。合同到期没发现,可能造成自动续约或者权益损失,所以这个功能必须做到"该提醒的一定要提醒到,不该提醒的别瞎提醒"。
我实现的逻辑是每天凌晨执行一次定时任务,扫描所有status='EFFECTIVE'的合同,把未来30天内到期和已经过期未处理的合同找出来。每个合同生成一条提醒记录:
@Component public class ContractExpireRemindTask { // 每天凌晨1点执行 @Scheduled(cron = "0 0 1 * * ?") public void scanExpiringContracts() { LocalDate today = LocalDate.now(); LocalDate remindDeadline = today.plusDays(30); List<ContractInfo> expiringContracts = contractMapper.selectList( new LambdaQueryWrapper<ContractInfo>() .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方法,都查到了status=PENDING,都走完了校验,都往审批记录表插入数据,然后都试图把主表更新为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天能做到基础台账、审批、提醒、文件管理全部跑通,对一套内部管理系统来说已经够用了,剩下的都是锦上添花。做项目也是一样,先把主干立起来,再考虑枝叶,不然容易陷入无休止的需求旋涡里。