我从一个已经写了8天的开发记录里拷贝出了昨天的代码,然后构建了今天的合同管理系统核心模块。整个项目叫 contract-management,目标是让“合同”这个原本困在邮件、Excel和共享文件夹里的东西,变成一套可追踪、可审批、可预警、可审计的系统。这篇文章适合所有在写企业级管理系统的开发者,尤其是想搞定“审批流+数据权限+金额精度”这类硬骨头的人。
我先把话说在前面:合同管理系统看起来只是“增删改查”,实际做起来远远不止。它牵涉到业务状态流转、财务金额规范、敏感数据权限、多部门协作,甚至法务合规。今天我把第9天的实际开发过程拆开来讲,包括技术选型、数据库设计、审批流实现、权限模型,以及我在真实编码中踩进去又爬出来的坑。
1. 为什么第9天我会选择做合同管理系统:需求梳理比代码更重要
很多人觉得合同管理系统无非就是电子化存档——把PDF传上去,留个录入页面,再做个列表查询。这是最大的误解。等我把真实业务方拉过来聊了半小时,需求就膨胀成了四个大块:合同全生命周期管理、审批流转、履行跟踪、权限与审计。
1.1 合同的全生命周期到底指什么
一份合同从“意向沟通”到“归档销毁”,大致分成这么几个阶段:
- 起草:合同文本可以由业务人员直接上传,也可以基于标准模板生成。
- 审批:法务、财务、分管领导、总经理按金额和类型走不同路径。
- 签署:电子签或线下签署后回传扫描件。
- 履行:记录每一笔收款、付款、发货、验收。
- 变更:补充协议、延期、金额调整,需要保留版本痕迹。
- 到期与归档:合同到期前提醒,到期后自动归档。
我今天把第1到第4阶段的核心骨架做了,第5和第6阶段留了接口。
1.2 合同管理的三大核心痛点
我在梳理需求时发现,通常企业之所以要上合同管理系统,不是因为缺一个“存文件的网盘”,而是因为下面三个问题已经痛到不能再拖:
- 版本混乱:同一份合同的 Word 在几个人的电脑里分别被改过,最后盖章版本和线上版本对不上。所以系统必须解决“版本留痕”和“当前有效版本”的问题。
- 履约失控:合同签完就没人管,该收的钱没收回来,该续签的没续签,等发现已经造成损失。所以系统必须能记录收付款计划,并且自动触发提醒。
- 权力边界模糊:销售能看全公司所有合同金额,这在大多数企业是不被允许的。合同数据天然敏感,数据权限必须在后端做硬控制,而不是前端隐藏字段。
我把这些整理成了一张需求优先级表:
| 模块 | 优先级 | 说明 |
|---|---|---|
| 合同台账与文件管理 | P0 | 没有这个系统就没有存在意义 |
| 审批流 | P0 | 合同必须过审批才能盖章 |
| 收付款计划与提醒 | P1 | 防止履约失控 |
| 数据权限与审计 | P1 | 敏感数据合规要求 |
| 变更与版本对比 | P2 | 下一迭代再做 |
| 电子签集成 | P2 | 已有第三方印章平台,后续对接 |
明确优先级之后,我才敢动手写代码。这是我个人做系统的一个经验:业务优先级没排清楚就写代码,大概率做出来一堆没人用的页面。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:够用的框架加能维护的结构,比盲目上全家桶稳得多
合同管理系统本质上是一个“业务逻辑重、并发压力低、数据准确性要求高”的企业级应用。我在选型时没有追新,而是按团队维护成本来定。
2.1 后端框架:Spring Boot 3 + PostgreSQL
后端我选了 Spring Boot 3.2,JDK 17。为什么不选别的?团队最熟,招人最容易,Spring生态对于事务、安全、数据访问的成熟度是经过大量生产环境验证的。这种管理系统拼的不是谁的框架新,而是谁能在三个月后还改得动代码。
持久层我用 PostgreSQL 15。PG 的 NUMERIC 类型可以精确存储金额,自带 JSONB 方便扩展字段,加上行级安全策略(RLS)比较成熟,这对后面的数据权限设计很关键。MySQL 不是不行,但既然要处理复杂业务和权限,PG 的优先级会更高。
ORM 我用的是 MyBatis-Plus 而不是 JPA。原因很简单:合同查询经常要拼多表动态条件,MyBatis-Plus 的 QueryWrapper 写起来直白,复杂SQL可控性也更强。真要上 JPA 的 Specification,反而要把查询逻辑绕一大圈。
2.2 前端框架:Vue 3 + Element Plus
前端用了 Vue 3 + Vite + Element Plus。不夸张地说,Element Plus 的 Table、Form、Tree 组件直接把管理系统的开发速度拉快一倍。
我原本考虑过 Ant Design Vue,但最后还是选了 Element Plus,纯粹是因为合同管理这种“表单密集型”应用里,Element Plus 的表单校验和表格操作更顺手,文档也全,遇到问题好搜。
前后端联调用的是 RESTful API,鉴权直接用 Sa-Token。Sa-Token 在国产框架里属于上手极快的一类,支持登录、权限认证、Redis 集成,省去了自己写拦截器和上下文工具的功夫。
2.3 为什么没有上微服务和消息队列
合同管理系统在绝大多数企业里都达不到需要微服务的并发量。如果这个项目上了 Nacos、Feign、RocketMQ 全家桶,那是给自己找罪受。单应用加多模块,已经足够。提醒功能我用 Spring 自带的 @Scheduled 加一个任务表来实现,不引入消息中间件,理由是逻辑简单、出问题好排查、部署也不用多养一个组件。
提示:选型一定要从团队实际情况出发。如果团队是 Node 技术栈,那用 NestJS 写同样能达到目的,没必要因为这篇文章就换语言。
3. 合同数据模型:一张主表装不下所有业务
合同表如果只设计成 id, contract_no, title, amount, file_url, status,那开发确实快,但后续推进到审批、履约、变更时必然要重构。我今天把数据模型拆成了“主表 + 子表 + 扩展表”的结构。
3.1 核心表结构:合同头、合同条款、收付款计划、附件
我不追求把整个企业级合同系统所有表都一次性建完,但至少要把下面这些核心表跑通:
contract_header(合同主表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键,雪花算法 |
| contract_no | VARCHAR(64) | 合同编号,唯一 |
| contract_name | VARCHAR(255) | 合同名称 |
| contract_type | VARCHAR(32) | 类型:采购/销售/框架/其他 |
| party_a | VARCHAR(255) | 甲方 |
| party_b | VARCHAR(255) | 乙方 |
| total_amount | NUMERIC(18, 2) | 含税总金额 |
| currency | VARCHAR(8) | 币种,默认CNY |
| status | VARCHAR(32) | 当前状态 |
| effective_date | DATE | 生效日期 |
| expire_date | DATE | 到期日期 |
| owner_id | BIGINT | 合同负责人 |
| dept_id | BIGINT | 所属部门 |
| tenant_id | BIGINT | 租户/公司 |
| deleted | TINYINT | 逻辑删除 |
| created_time / updated_time | DATETIME | 审计时间 |
contract_clause(合同条款表)
不要把所有条款塞进主表一个长文本字段。拆出来之后,以后想做“条款级别的检索”和“模板条款比对”就有抓手。
- id
- contract_id(外键)
- clause_type(付款条款 / 交付条款 / 违约责任 / 保密条款)
- clause_title
- clause_content(TEXT)
- sort_order
标准化条款还有“不可修改级别”,这个后续做变更管理会用到,我先把字段预留为 editable。
contract_payment_plan(收付款计划表)
合同管理的核心价值之一就是履约提醒。每一个合同可以有多条收付款计划:
- id
- contract_id
- plan_type(应收 / 应付)
- amount
- due_date
- status(未到期 / 已提醒 / 已逾期 / 已完成)
- actual_amount
- actual_date
- remark
contract_attachment(附件表)
附件不能直接扔文件服务器就不管了,系统里需要有元数据记录:
- id
- contract_id
- file_name
- file_url
- file_size(单位:字节)
- file_sha256(防篡改校验)
- uploaded_by
- upload_time
我特意加了一个 file_sha256 字段。合同扫描件是法律凭据,如果文件被替换或损坏,哈希值对比能第一时间发现问题。这是很多管理系统会漏掉但特别有价值的字段。
3.2 金额与币种:总额字段必须用 NUMERIC,锁定精度
这是合同系统里最不能妥协的一个点。数据库层面金额字段一律用 NUMERIC(18, 2),Java 里对应 BigDecimal,禁止用 double、禁止用 float。
为什么?0.1 + 0.2 在二进制浮点运算是 0.30000000000000004。开发环境大概率测不出来,但合同金额一旦牵扯到利息计算、税费分摊、履约碳排,最后对不上账,用户不会管你是 float 还是 double,只会认为系统算错了。按很高的查账标准,金额绝不能有误差。
我封装了一个金额工具类,核心方法就是保证运算结果精确到分,并且统一处理了“四舍五入”的规则。在 Java 里还要注意:BigDecimal.valueOf(0.01) 和 new BigDecimal(0.01) 不是一个东西。前者是字符串转换,值就是 0.01;后者是二进制浮点转换,会得到一长串。这个坑我当年真的踩过,这次直接记到规范里。
3.3 合同变更与版本:不是 UPDATE,而是 INSERT
合同属于典型“改不得”的数据,不是说不能改,而是所有修改必须留痕。今天的实现里,我在 contract_header 表上加了 version_no 字段,所有变更走新增版本逻辑:
- 变更前把当前记录复制一份到
contract_header_history表; - 变更操作只会更新主表的
status、version_no、effective_date等字段; - 业务关键字段(金额、相对方、履行期限)不允许直接 update,必须走“变更单”(change order)。
这样设计的好处,后续法务审计时可以快速回答“这份合同最初签的是多少钱、中间改过几次、每次改了谁批的”。
4. 审批流与状态机:让合同流转起来但不失控
合同管理系统最容易被高估的是技术难度,最容易被低估的是状态设计。合同从创建到归档,如果状态全靠 status=1/2/3 硬编码,后面每加一个审批节点都会想哭。
4.1 明确合同状态机
我这两天把状态机画得非常具体(不是流程引擎图,就是业务约定),核心状态如下:
DRAFT草稿:业务人员创建,可编辑PENDING_APPROVAL审批中:提交后锁定编辑APPROVED审批通过,待签署IN_EFFECT履行中,签署完成且生效EXPIRED已到期TERMINATED提前终止ARCHIVED已归档
在代码里,我不是用简单的 if/else 到处判断状态,而是用策略模式 + 状态机配置表来控制“当前状态允许哪些动作转向目标状态”。例如:
- DRAFT → PENDING_APPROVAL(提交审批)
- PENDING_APPROVAL → APPROVED(审批通过)
- PENDING_APPROVAL → DRAFT(审批驳回,回到草稿)
- APPROVED → IN_EFFECT(登记签署生效)
- 归档状态不允许再发起变更,除非走“重新激活”流程
4.2 自制轻量版审批引擎,而不是引入工作流框架
很多人一提审批流就想去接 Flowable、Activiti 这类重量级工作流引擎。我说句实话:如果你们的审批流程就是按金额/合同类型走固定层级,大概率不需要上工作流引擎。 工作流引擎的学习成本、部署成本、BPMN建模成本,对一个小型合同系统来说是过重的。
我选择自己实现一个轻量级审批引擎,表结构如下:
approval_config(审批配置表)
| 字段 | 说明 |
|---|---|
| id | 主键 |
| biz_type | 业务类型,如 CONTRACT |
| amount_min / amount_max | 金额区间 |
| dept_scope | 适用部门 |
| approval_node | 节点序号:1, 2, 3 |
| approver_type | 审批人类型:ROLE / USER / DEPT_MANAGER |
| approver_value | 对应角色ID或用户ID |
approval_record(审批记录表)
- id
- biz_id(合同ID)
- node
- approver_id
- action(AGREE / REJECT / RETURN)
- comment
- created_time
启动审批的时候,代码根据合同金额和类型查出配置节点,然后生成一批待办任务。
4.3 会签、或签、条件分支怎么实现
我用一个 node_mode 字段区分三个模式:
AND(会签):所有审批人都同意才算通过。统计total_count和approved_count,相等才进入下一节点。OR(或签):任一审批人同意即通过。只要有一个人同意,本节点直接完成。SEQ(依次审批):必须按节点顺序审批,上一节点通过才放行下一个节点的待办。
条件分支主要靠金额阈值:例如“单笔合同超过100万”必须经过总经理节点,“低于5万”只需部门经理审批。
这套引擎写下来大约200行核心逻辑,但完全可以满足合同审批场景。跑通之后,后续如果要接更复杂的流程,再替换也不晚。提前上重型引擎,反而是过度设计。
5. 合同履行:审批通过那一刻,工作才真正开始
合同管理是典型的“重审批、轻履行”典型问题是——合同盖完章就没人管了。所以今天的开发重点,我刻意把履行跟踪放到了很靠前的位置。
5.1 收付款提醒:定时任务加任务表,而不是直接写在内存里
我做一个 reminder_task 表,每次扫描时把到期的收付款计划生成提醒任务:
| 字段 | 说明 |
|---|---|
| id | 主键 |
| biz_type | CONTRACT |
| biz_id | 合同ID |
| payment_plan_id | 收款计划ID |
| remind_time | 提醒时间 |
| remind_status | PENDING / SENT / DONE |
| channel | 通知渠道,如站内信、企业微信 |
定时任务我用 @Scheduled 每 10 分钟跑一次,逻辑是:找出所有 due_date 在未来 7 天内(或已过期)且还没有提醒过的计划,批量生成待办。
为什么不用 cron 表达式精确到秒?因为合同提醒不是秒级实时业务,10分钟一次的延迟完全不影响业务。如果需要立即通知,用户也完全可以手动触发“立即提醒”。
5.2 关联业务单据:合同不能只挂合同
真正用起来之后,合同下面的收付款计划、验收记录、发票信息会越来越多。我在履行模块里做了一个 contract_performance_item 表,专门存“履行事项”,每一条可以关联到收付款计划或附件。
这么做有一个好处:当收付款计划逾期时,业务人员在合同详情页可以看到是“哪一笔钱、对应哪个验收节点、卡在哪个环节”,而不需要自己去翻Excel。
5.3 履约超时预警的计算逻辑
逾期预警不能简单地用 due_date < today 判断就完事,因为节假日要顺延。我写了一个 BusinessDateUtil,把法定节假日配置表引进来,判断“逾期”时按工作日计算。
比如,付款计划约定的是“验收后15个工作日内付款”,那逾期时间就要从验收次日开始数15个工作日。如果只用自然日历算,遇到春节会算出大量假逾期,提醒效果大打折扣。
注意:这个“工作日计算”功能表面看是个工具类,实际可能会很热闹,需要维护节假日配置。最好在后台留一个
holiday_config维护页面,哪怕只是导入Excel。
6. 权限设计:合同数据贵在隔离,不能只靠前端藏按钮
合同金额、合作方、付款条件都是高度敏感的数据。权限这块,如果只在菜单里做区分,那用户完全可以通过直接调用后端接口越权查看。必须把权限控制到后端接口和数据行级别。
6.1 三种数据权限范围:全部、本部门、仅本人
我用最通用的三级数据权限模型:
| 数据权限 | 说明 |
|---|---|
| ALL | 可查看全公司合同 |
| DEPT | 可查看本部门合同 |
| SELF | 只可查看自己创建的/负责的合同 |
实现方式并没有在每个查询里拼接 if,而是用 MyBatis-Plus 的拦截器统一注入数据权限SQL片段。核心逻辑是:
- 自定义一个
DataScopeAspect,在Service层方法上标注@DataScope(tableAlias = "c"); - 从当前登录用户上下文取出权限范围;
- 如果是
SELF,拦截器自动拼上AND c.owner_id = 当前用户ID; - 如果是
DEPT,拼上AND c.dept_id = 当前部门ID。
这样做的好处是业务代码里不用到处传 userId,也不会漏掉任何一个查询接口。
6.2 行级权限与租户隔离
如果你的系统是多公司/多租户架构,还需要考虑隔离。我在所有核心表里都有 tenant_id 字段,MyBatis-Plus 提供了多租户插件,会自动在 SQL 语句后面追加 AND tenant_id = ?。
这里有个开发中容易踩的坑:多租户插件最好只对特定表生效,关联查询时如果某张表没有 tenant_id,会导致 SQL 报错“column not found”。我的做法是配置忽略部分字典表、日志表,只对业务主表强制隔离。
6.3 审计日志:谁在什么时候看过什么
合规审计不能省。用户在查看合同详情、下载附件、修改合同时,必须留下操作记录。我这里实现了一个简单的 audit_log 表:
- operator_id
- operator_name
- operation(VIEW / DOWNLOAD / UPDATE / APPROVE / REJECT)
- target_type(CONTRACT)
- target_id
- detail(JSON 格式的备注)
- ip
- created_time
写日志用 Spring 的 @Aspect 注解,在关键方法上挂切面。注意,审计日志的写入要保证不阻塞主流程。我采取了“异步写入”策略:先放进队列,由独立线程批量落库。系统规模小,完全够用。
刚提到异步写入,我要提醒一句:异步任务重启时会丢消息。对于审计日志,如果要求严格,那就用同步写入,无非多一点IO开销。合同系统并发量低,同步写完全不会造成性能问题。我最后的选择其实是同步写,省心。
7. 开发过程中踩到的坑和解决方案
第9天开发过程中,我踩了几个蛮有代表性的坑,写出来给大家排雷。
7.1 金额计算里的 BigDecimal 构造函数陷阱
这个问题虽然基础,但很经典。写代码时如果不小心用了 new BigDecimal(0.1),拿到的不是精确的0.1,而是0.1000000000000000055511151231257827021181583404541015625。
正确写法只有两种:
java复制BigDecimal amount = new BigDecimal("1000.00"); // 推荐:字符串构造
BigDecimal amount2 = BigDecimal.valueOf(1000.00); // 底层也是字符串转换
我在金额工具类里直接禁止了 new BigDecimal(double) 这种用法,并且统一封装了加、减、乘、除方法。除法必须传精度和舍入模式,否则除不净会抛 ArithmeticException。
7.2 同一条合同被两个人同时编辑:并发更新
如果不做控制,两个人同时打开一份草稿合同的详情页,A 改了金额保存,B 改完相对方保存,后保存的人会把前者的修改覆盖掉。
我加了一个 version_no 乐观锁字段:
- 查询时返回当前
version_no; - 更新时带上前端传来的
version_no作为条件; - 如果
UPDATE ... WHERE id = ? AND version_no = ?影响行数为0,说明版本已经变化,直接提示“数据已被修改,请刷新”。
这个方案简单有效,不需要引入分布式锁。
7.3 定时任务重复执行:多实例部署时会导致提醒发两遍
如果将来系统用多实例部署,@Scheduled 默认在每个实例上都会执行,导致一份提醒被发两次。
解决方式有三个方向:
- 使用
xxl-job这类分布式调度框架; - 在数据库任务表上做唯一索引,插入提醒任务时利用唯一键去重;
- 引入 Redis 分布式锁,只允许一个实例执行扫描任务。
我这里用了“唯一索引 + INSERT ON CONFLICT DO NOTHING”的方式,简单直接,不需要额外依赖。
sql复制CREATE UNIQUE INDEX uk_reminder ON reminder_task (biz_type, biz_id, payment_plan_id, remind_time);
重复执行时,第二个实例插入会冲突,然后跳过即可。
7.4 时间与时区:日期字段用错类型导致合同到期日多一天
合同到期日属于日期(无时间),如果Java代码里用了 new Date() 加 LocalDateTime 去比较,很容易因为时区差造成结果错位。例如北京时间是 UTC+8,存储到 timestamp without time zone 时如果没转换好,查询出来的日期可能差一天。
我的规范:数据库日期字段用 DATE,Java 用 LocalDate;时间字段用 TIMESTAMP,Java 用 LocalDateTime;接口传输统一 ISO 格式字符串,不传递时间戳毫秒数。这样基本能避免时区问题。
8. 实测结果与后续规划:从能跑到敢用的距离
今天把系统跑通后,我造了大概100份测试合同数据,做了几个关键验证:
- 合同从创建到提交审批,再到一级审批、二级审批通过,全流程正常;
- 5万以下合同走部门经理节点,50万以上自动加签财务和总经理节点,条件分支生效;
- 收付款计划到期提醒在定时任务触发后生成待办,记录不重复;
- 销售角色访问非本部门合同返回无权限,直接调用接口也拿不到数据;
- 金额字段累计汇总测试,多次计算的结果与 Excel 精确计算完全一致。
当前版本还缺一些功能,但已经能用于内部试点。后续我计划把电子签集成接入,把合同模板管理做起来,同时把变更单业务补全。合同管理系统的价值会随着数据积累越来越大,现在把“审批、履行、权限、审计”这四个根基建稳,后面加功能就不会伤筋动骨。
根据我自己的经验,做这类系统最重要的不是炫技,而是把企业里那些“看不见但真实存在”的流程规则理解透。如果你也想做合同管理系统,我的建议是:先梳理清楚自己的真实审批流程和权限边界,再动手设计表结构。数据模型稳了,后面就顺了。还有一个小经验值得分享——合同列表的查询性能,后半程大概率会成为关注点,建议从一开始就在合同编号、到期时间、状态上建好索引,别等数据量大了再来补救。
