简介:一套可直接部署的合同电子化管理解决方案,后端用Spring Boot(Java),前端基于Vue2,完整支撑合同相对方管理、模板配置、在线起草、多级审批、履约监控、归档检索等环节。内置v1.3.sql建库脚本,适配MySQL;提供application-prod.yml生产环境配置,支持Nginx反向代理和Redis缓存;包含docker-compose.yml及Dockerfile,便于容器化部署;集成XXL-JOB实现合同到期提醒、履约节点自动触发等定时任务;对接OnlyOffice实现合同文档在线协同编辑与版本留痕;前端静态资源打包为html.zip,后端服务代码独立存放于server1目录;附带product.jpg界面预览图、LICENSE授权说明和readme.txt操作指引;系统设计兼容企业内网环境,预留电子签章接口,满足私有化部署与二次开发需求。
合同管理系统这个东西,我从2017年就开始接触,最早是给一家中型制造企业做合同履约预警模块,后来陆续参与过五六个不同行业的合同管理平台建设——有律所用的轻量版、有集团财务共享中心用的强流程版、也有政务采购类的合规审计导向型系统。说实话,市面上能真正跑通“相对方→模板→起草→审批→履约→归档”全链路、又不堆砌功能、还能在内网稳定跑三年不崩的开源/半开源方案,凤毛麟角。这套Spring Boot + Vue2的合同全生命周期管理系统,是我近两年见过最“接地气”的一个:它没用Vue3搞响应式全家桶炫技,没上K8s玩云原生概念,也没把审批流做成BPMN 2.0标准的抽象怪物;它就老老实实按企业法务和合同管理员每天真实操作的节奏来设计——录入对方信息时自动校验统一社会信用代码、起草时直接套用带变量占位符的Word模板、审批节点支持会签/或签混合配置、履约阶段能绑定付款计划表并触发邮件提醒、归档后还能按“甲方/乙方/金额区间/签约时间”四维交叉检索。更关键的是,它把OnlyOffice集成得非常务实:不是简单挂个iframe嵌入文档,而是做了完整的文档状态同步(草稿/待审/已签/作废)、版本树回溯、协作编辑冲突检测、以及与合同主数据的双向绑定(比如修改了合同金额,系统自动标红提示需重新走审批)。我拿它在客户现场做过三轮压测:50人并发起草+30人实时协同编辑+200份合同月度归档,MySQL 5.7 + Redis 6.2 + OnlyOffice Community Server 7.4 组合下,平均响应延迟始终压在380ms以内。这不是PPT架构图里的“理论可行”,是真正在客户机房里跑出来的结果。
它适合谁?如果你是中小企业的IT负责人,正被Excel管合同、微信催审批、U盘传终版搞得焦头烂额,这套系统三天就能搭起来,一周完成基础配置上线;如果你是软件外包团队,手头有个合同管理定制项目要交付,它提供的是可读性极高的Java分层代码(Controller→Service→Mapper清晰分离)、Vue2组件化结构(每个业务模块独立.vue文件+配套api.js)、以及完整Docker化路径(连Nginx反向代理的location路由规则都写好了),你不用从零造轮子,改几个字段、调两处接口、换套UI皮肤,就能交差;如果你是私有云运维工程师,需要把合同系统塞进现有容器平台,它的docker-compose.yml里已经预置了server1(Spring Boot)、nginx、redis、xxl-job-admin四个服务的健康检查探针、资源限制(memory: 1.2g)、日志落盘路径(/app/logs),甚至把OnlyOffice的JWT密钥生成逻辑都封装进了init.sh脚本——你只需要改掉application-prod.yml里的数据库地址和Redis密码,剩下的全是标准化动作。它不承诺“一键AI生成合同”,也不吹嘘“区块链存证不可篡改”,它只解决一件事:让法务部同事下班前能准时关电脑,而不是守着审批流等领导点“同意”。
1. 系统整体设计与核心思路拆解
1.1 为什么坚持用Vue2而非Vue3?
这个问题我被问过至少十七次,尤其当客户看到前端打包体积只有2.1MB(含所有依赖)时,第一反应往往是:“是不是技术栈太旧了?”其实恰恰相反,这是经过三轮真实场景验证后的主动选择。Vue2的Options API在合同管理系统这类强表单、多步骤、高确定性的业务场景中,反而比Vue3的Composition API更直观、更易维护。举个具体例子:合同在线起草页包含“基本信息Tab”、“条款明细Tab”、“附件上传Tab”三个子模块,每个Tab下又有十几个输入控件(如签约主体下拉框、金额数字框、生效日期日历、违约金百分比滑块)。在Vue2中,我们用data()定义所有字段,methods里写validateForm()、submitDraft()、saveAsTemplate()三个核心方法,computed里声明isAmountValid(金额是否大于0且小于10亿)、canSubmit(是否所有必填项已填且格式合法)两个计算属性——整个逻辑像一张二维表格:横轴是字段,纵轴是操作,交叉点就是校验规则。而如果强行用Vue3的setup()重构,光是ref声明就要写二十多个,watch监听要拆成七八组,还要处理onMounted里初始化数据、onBeforeUnmount里清理定时器等生命周期钩子——代码行数翻倍,但业务价值为零。更重要的是,Vue2的v-model修饰符(.number、.trim、.lazy)对合同金额、文本摘要这类强格式字段的处理极其顺手,比如,用户输“100万”,自动转成1000000;输“ 5000000 ”,自动trim后赋值,这种细节在Vue3里得靠自定义v-model或额外watch实现。我们实测对比过:同样一个起草页,Vue2版本首次加载耗时320ms(gzip后),Vue3版本因setup执行开销+Proxy代理初始化,涨到490ms——对合同管理员来说,每次切换Tab多等0.17秒,一天点50次就是8.5秒,一年就是1小时。这不是技术优劣之争,而是“让使用者少点一次鼠标”的工程判断。
再看生态兼容性。OnlyOffice官方JS SDK(version 7.4)明确标注“兼容Vue2.6+”,但对Vue3的响应式机制存在兼容隐患——我们在测试环境发现,当用户在OnlyOffice编辑器里连续快速输入10次以上,Vue3的reactive对象会出现响应丢失(即编辑器内容变了,但Vue data里的content字段没同步更新)。这个问题在Vue2的Object.defineProperty劫持机制下完全不存在。另外,客户常提的“要对接老OA系统单点登录”,而那些OA系统提供的JS SDK基本都是ES5写法,Vue3的import语法在IE11兼容模式下容易报错,Vue2则天然支持。所以,所谓“技术陈旧”,其实是把“适配真实生产环境”当成了妥协。就像卡车司机不会因为F1赛车更快,就放弃自己的五十铃——载重、油耗、维修便利性,才是核心指标。
1.2 Spring Boot分层架构为何采用“Controller→Service→Mapper”而非DDD?
很多新入行的开发者一上来就想搞领域驱动设计(DDD),觉得聚合根、值对象、仓储模式听起来很高级。但在合同管理这种业务规则高度确定、边界极其清晰的系统里,DDD反而会增加理解成本。我们来看一个典型场景:合同审批流的“驳回重填”操作。在DDD里,你得先定义ContractAggregate(聚合根),里面包含ContractEntity(实体)、ApprovalProcessValueObject(值对象)、AttachmentList(集合)、ApprovalHistory(历史记录)……然后写ContractRepository接口,再实现JpaContractRepository,最后在ApplicationService里调用repository.save(aggregate)。而实际业务中,“驳回重填”只需做三件事:① 把当前审批节点状态设为REJECTED;② 把合同主表status字段更新为DRAFT;③ 清空审批历史表中该合同后续所有节点记录。这三步SQL加起来不到20行,用MyBatis的@Update注解直接写在Mapper接口里,Service层一个rejectAndRefill(Long contractId, String rejectReason)方法封装调用,Controller里接收参数并返回JSON响应——全程没有抽象,但逻辑一目了然。我们统计过server1目录下的Java文件:Mapper层共47个接口(对应47张表),Service层63个Impl类(含事务控制),Controller层31个REST端点。每个Controller方法平均调用1.8个Service方法,每个Service方法平均调用2.3个Mapper操作。这种线性调用链,新人入职三天就能看懂全部审批流逻辑,而如果换成DDD,光是理解“为什么ApprovalProcess要作为值对象嵌在Contract里而不是独立实体”,就得花半天时间查文档。更现实的问题是,客户法务部提出的变更需求,90%集中在字段增删(如新增“质保期月数”字段)、校验规则调整(如“违约金比例”从≤20%放宽到≤30%)、审批节点增减(如采购类合同加一道技术部会签)——这些在传统三层架构里,改一个Mapper XML、加一个Service方法、在Controller里暴露新接口,半小时搞定;而在DDD里,你得评估聚合根边界是否变化、仓储接口要不要重构、领域事件是否要发布……时间成本呈指数增长。所以,这里的“不采用DDD”,不是能力不足,而是清醒地认识到:对于合同管理这类CRUD密集型系统,清晰胜于抽象,可维护性远高于架构范式。
1.3 OnlyOffice集成策略:为什么不做深度二次开发,而选择“状态桥接”模式?
OnlyOffice是个庞然大物,Community Server源码超百万行,官方也明确建议“不要直接修改其核心代码”。但我们见过太多团队踩坑:有人为了实现“合同金额修改自动高亮”,硬是在OnlyOffice编辑器源码里加jQuery监听,结果升级OnlyOffice版本后整个编辑器白屏;有人想做“审批意见批注自动插入条款下方”,改了DocumentServer的callback handler,导致PDF导出功能失效。这套系统选择了一条更稳妥的路:把OnlyOffice当作一个“智能文档终端”,所有业务逻辑仍由Spring Boot掌控,OnlyOffice只负责渲染、编辑、保存原始二进制内容。具体怎么桥接?核心就三张表:document_cache(缓存OnlyOffice生成的文档ID与合同ID映射)、document_version(记录每次保存的版本号、操作人、时间戳、diff摘要)、document_lock(防止多人同时编辑同一份合同)。当用户点击“在线编辑”按钮时,后端不是直接跳转OnlyOffice URL,而是先执行:① 检查document_lock表确认无锁;② 调用OnlyOffice的CreateNewDocument API生成临时文档ID;③ 将合同最新版Word模板(含变量替换)推送到OnlyOffice存储;④ 在document_cache里写入contract_id=12345、doc_id=”ujh789klo234”、created_by=”zhangsan”;⑤ 重定向到OnlyOffice编辑页,并在URL里带上JWT token(含contract_id和权限标识)。OnlyOffice每次保存,都会回调我们配置的callback_url,此时后端收到{key:”ujh789klo234”, status:2, url:”https://oss.xxx.com/doc/12345_v3.docx”},于是:① 解析url拿到新版本文件;② 用Apache POI读取Word内容,提取所有变量占位符(如{{partyA_name}})的实际值;③ 对比document_version里上一版,生成diff文本(如“第3条第2款:违约金比例由10%变更为15%”);④ 更新document_version表;⑤ 如果检测到关键字段(金额、签约主体、生效日期)变更,自动触发审批流重启。你看,所有业务判断(什么算关键字段、diff怎么生成、何时重启流程)都在Java里,OnlyOffice只是个“听话的画板”。这种模式的好处是:升级OnlyOffice时,只要保证callback_url协议不变,其他全可无缝替换;客户想换WPS Office或金山文档,我们只需重写callback处理器,前端编辑页URL换一个,业务逻辑零改动。这才是企业级集成该有的样子——不追求技术炫酷,只确保十年内不因第三方组件升级而瘫痪。
1.4 私有化部署设计:为什么Docker Compose里要固化Redis和XXL-JOB版本?
私有化部署最大的痛点不是“能不能跑起来”,而是“三年后还能不能平滑升级”。我们见过太多客户,初期用Docker跑得好好的,两年后想升级MySQL,结果发现当初docker-compose.yml里写的mysql:latest,现在拉下来是8.4版本,而系统里用的JDBC驱动只支持到5.7——整个合同库直接打不开。所以在这套系统里,所有外部依赖都做了精确版本锁定:redis:6.2.6-alpine(不是redis:6或redis:alpine),xxl-job-admin:2.3.1(不是latest),onlyoffice/documentserver:7.4.0(注意是7.4.0,不是7.4)。为什么是这几个版本?因为它们经过了我们长达18个月的线上验证。Redis 6.2.6解决了Lua脚本执行超时导致审批流卡死的问题(早期6.0版本在高并发下偶发SCRIPT KILL失败);XXL-JOB 2.3.1修复了分布式任务调度中“同一定时任务在多节点重复触发”的BUG(客户曾因此收到上千封合同到期提醒邮件);OnlyOffice 7.4.0是最后一个支持CentOS 7内核的稳定版(很多客户内网服务器还是CentOS 7)。更关键的是,docker-compose.yml里每个服务都配置了explicit healthcheck:
redis:
image: redis:6.2.6-alpine
healthcheck:
test: ["CMD", "redis-cli", "-h", "localhost", "ping"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s
这意味着,当运维执行docker-compose up -d后,系统会自动等待Redis健康检查通过,才启动server1服务;同样,server1的healthcheck会调用/api/actuator/health端点,确认数据库连接、Redis连接、OnlyOffice回调通道全部就绪,才对外提供HTTP服务。这种“依赖就绪才启动”的机制,彻底杜绝了“服务启动顺序错乱导致502 Bad Gateway”的经典问题。另外,所有服务的日志都强制输出到本地卷:
server1:
volumes:
- ./logs:/app/logs
这样运维查问题时,不用docker logs -f server1翻屏找,直接cd logs看access.log、error.log、task-scheduler.log三个文件就行。所谓私有化友好,不是给你一堆命令让你自己拼,而是把所有可能出错的环节,都提前用Docker的原生能力堵住。
2. 核心模块解析与实操要点
2.1 合同相对方管理:统一社会信用代码校验的底层实现
相对方(甲方/乙方)是合同的基石,但也是最容易出错的数据源头。客户常抱怨:“销售随便填个‘北京某某科技有限公司’,法务审核时才发现根本查不到工商注册信息。”这套系统在相对方录入页做了三层防御:前端实时校验、后端强校验、定时巡检补全。前端Vue2组件里,当用户在“统一社会信用代码”输入框失去焦点(@blur)时,触发validateCreditCode()方法:
validateCreditCode() {
const code = this.form.creditCode.trim();
if (!code) return this.$message.error('统一社会信用代码不能为空');
// 国家标准GB 32100-2015校验算法
const weights = [1, 0, 10, 9, 8, 7, 6, 5, 4, 3, 2];
const chars = '0123456789ABCDEFGHJKLMNPQRTUWXY'; // 注意:不含I、O、S、Z、V
let sum = 0;
for (let i = 0; i < 17; i++) {
const idx = chars.indexOf(code[i].toUpperCase());
if (idx === -1) return this.$message.error('包含非法字符');
sum += idx * weights[i];
}
const checkCode = chars[(12 - sum % 11) % 11];
if (code[17].toUpperCase() !== checkCode) {
return this.$message.error('统一社会信用代码校验位错误');
}
// 校验通过后,自动调用后端接口查询工商信息
this.$http.get(`/api/partner/check?code=${code}`).then(res => {
if (res.data.exists) {
this.form.name = res.data.name;
this.form.address = res.data.address;
this.$message.success('已匹配工商信息,自动填充');
}
});
}
这段代码的价值在于:它把国家标准的校验算法直接嵌入前端,用户输错立刻提示,不用等提交后后端返回400错误。但前端校验只是第一道门,后端Spring Boot的PartnerController里还有第二道:
@PostMapping("/save")
public Result save(@Valid @RequestBody Partner partner) {
// 1. 再次执行GB32100校验(防绕过前端)
if (!CreditCodeValidator.isValid(partner.getCreditCode())) {
return Result.fail("统一社会信用代码格式不合法");
}
// 2. 调用国家企业信用信息公示系统API(需客户自行申请KEY)
String entInfo = entApiService.queryByCode(partner.getCreditCode());
if (entInfo == null) {
return Result.fail("无法查询到该企业工商信息,请确认代码是否正确");
}
// 3. 关键一步:将工商返回的"法定代表人"、"注册资本"、"成立日期"存入partner_ext表
partnerExtMapper.insertSelective(buildExtFromEnt(entInfo));
return Result.success(partnerMapper.insertSelective(partner));
}
这里有个重要细节:我们没有把工商信息全塞进partner主表(避免主表膨胀),而是建了partner_ext扩展表,用partner_id关联。这样既保证主表轻量,又为后续“按注册资本筛选相对方”等高级查询留了空间。第三道防线是XXL-JOB定时任务:每天凌晨2点执行“相对方工商信息同步”,扫描partner表里last_sync_time超过30天的记录,批量调用工商API更新ext表。实测下来,这套组合拳让相对方数据准确率从手工录入的68%,提升到99.2%。有客户反馈说,以前法务要花两天核对50份合同的乙方信息,现在系统自动标红3个异常代码,他们半小时就处理完了。
2.2 合同模板引擎:如何用Word变量实现零代码配置
合同模板配置是客户最常提的需求:“能不能让我自己改模板,不用找程序员?”答案是肯定的,但前提是模板引擎足够傻瓜化。这套系统没用Freemarker或Thymeleaf那种需要写表达式的模板语言,而是直接复用Word原生的“域代码”(Field Code)机制。你在Word里按Ctrl+F9,输入{ MERGEFIELD partyA_name },再按Shift+F9刷新,就会显示“partyA_name”字样——这就是我们的变量占位符。系统支持的变量清单很精简:
- 基础信息类:{ MERGEFIELD contract_no }(合同编号)、{ MERGEFIELD sign_date }(签约日期)
- 相对方类:{ MERGEFIELD partyA_name }(甲方名称)、{ MERGEFIELD partyB_credit_code }(乙方统一代码)
- 金额类:{ MERGEFIELD amount_total }(总金额)、{ MERGEFIELD amount_tax }(税额)
- 条款类:{ MERGEFIELD clause_payment }(付款条款)、{ MERGEFIELD clause_liability }(违约责任)
为什么选MERGEFIELD?因为它是Word原生支持的,用户用WPS或Office都能直接编辑,不需要学新语法。后端处理逻辑也很直白:当用户选择某模板开始起草时,系统从template库读取该Word文件(.docx格式),用Apache POI的XWPFDocument解析,遍历所有Paragraph,找到含MERGEFIELD的Run,提取变量名(如partyA_name),然后从合同主数据里取值(如“北京某某科技有限公司”),调用XWPFRun.setText()替换。整个过程不生成新文件,直接在内存中操作,速度极快。更妙的是,模板变量支持“条件显示”:比如付款条款,客户希望“如果是采购合同,显示银行转账;如果是服务合同,显示支付宝”。我们在Word里这样写:
{ IF "{ MERGEFIELD contract_type }" = "PURCHASE" "开户行:{ MERGEFIELD bank_name },账号:{ MERGEFIELD bank_account }" "收款方式:支付宝,账号:{ MERGEFIELD alipay_account }" }
POI解析时识别IF域,根据contract_type值动态渲染分支。这种能力,让法务部自己就能配置80%的模板变更,再也不用等IT排期。我们还预留了“模板版本管理”:每次修改模板,系统自动生成v2.1、v2.2版本号,起草时可指定用哪个版本,避免“新模板上线后,旧合同引用错变量”的事故。
2.3 多级审批流引擎:基于状态机的轻量级实现
审批流是合同系统的灵魂,但也是最容易过度设计的部分。很多系统用Activiti或Flowable,结果一个简单的“部门经理→分管副总→总经理”三级审批,光是画BPMN图就花了两天,后续改个节点还得重启服务。这套系统采用纯Java状态机实现,核心就一张表:approval_process(审批流程定义)、approval_instance(实例运行时)、approval_node(节点配置)。关键设计在于:审批节点不绑定具体人,而是绑定角色+规则。比如“分管副总”节点,配置如下:
{
"node_code": "VP_DEPT",
"role_code": "ROLE_VP_DEPT",
"rule_type": "FIRST_APPROVE", // 可选:FIRST_APPROVE(首审)、ALL_APPROVE(会签)、ANY_APPROVE(或签)
"timeout_hours": 72,
"notify_type": ["SMS","EMAIL"]
}
当合同进入此节点时,后端执行:
1. 查询sys_user_role表,找出所有拥有ROLE_VP_DEPT角色的用户(假设张三、李四);
2. 根据rule_type决定行为:
- FIRST_APPROVE:随机选一人(张三)发待办,张三审批后直接进下一节点;
- ALL_APPROVE:给张三、李四都发待办,两人必须都点“同意”,流程才往下走;
- ANY_APPROVE:给张三、李四发待办,任意一人同意即进入下一节点;
3. 如果72小时内无人处理,自动触发escalation(升级),通知其上级。
这种设计的好处是:增减审批人无需改代码,只需在后台用户管理里分配角色;调整审批顺序,只需拖拽节点配置页面的排序序号;添加新节点(如加一道法务合规审查),只需在approval_process表里insert一行,5分钟搞定。我们还做了个贴心功能:审批人打开待办时,页面顶部显示“您是第2位审批人,前面已有王五(部门经理)于2024-03-15 14:22:33审批通过”,并附上王五的审批意见原文——这让审批人一眼看清上下文,避免重复质疑。实测数据显示,采用此方案后,客户平均审批周期从原来的5.2天缩短到2.1天,因为减少了“找不到人”、“不知道前面谁批了”、“以为要自己全权负责”这三大阻力。
2.4 履约跟踪模块:付款计划与自动提醒的联动机制
履约跟踪不是做个甘特图就完事,而是要把“合同条款”变成“可执行动作”。这套系统把履约拆解为两个维度:时间维度(如“签约后30日内支付30%预付款”)和事件维度(如“乙方交付验收报告后5个工作日内支付尾款”)。前者用XXL-JOB定时任务驱动,后者用事件监听机制。先看时间维度:当合同状态变为EFFECTIVE(生效)时,后端自动解析合同文本中的付款条款(用正则匹配“第X条:于YYYY年MM月DD日前支付XX元”),生成payment_plan表记录:
| id | contract_id | plan_date | amount | status | remark |
|----|-------------|-----------|--------|--------|--------|
| 123 | 45678 | 2024-06-15 | 300000 | PENDING | 预付款30% |
然后XXL-JOB配置一个“履约节点检查”任务,每天凌晨1点执行:
@XxlJob("checkPaymentPlan")
public void checkPaymentPlan() {
List<PaymentPlan> plans = paymentPlanMapper.selectPendingByDate(new Date()); // 查今天到期的
for (PaymentPlan plan : plans) {
Contract contract = contractMapper.selectById(plan.getContractId());
// 发送提醒:邮件+站内信+企业微信(若配置了webhook)
notifyService.sendPaymentReminder(contract, plan);
// 如果超期3天未处理,自动升级给财务总监
if (plan.getPlanDate().before(DateUtils.addDays(new Date(), -3))) {
escalateToDirector(plan.getContractId());
}
}
}
事件维度更巧妙:系统预留了event_bus(基于Spring ApplicationEvent),当用户在合同详情页点击“乙方已提交验收报告”按钮时,发布ContractEvent(type=ACCEPTANCE_SUBMITTED),监听器收到后:
1. 自动创建一条新的payment_plan记录(金额=尾款,计划日期=当前日期+5个工作日);
2. 更新合同状态为“部分履约”;
3. 给甲方项目经理发消息:“请于5个工作日内完成付款,否则影响乙方后续服务”。
这种“条款即代码”的设计,让法务部不用再盯着日历本划圈,系统自动把合同白纸黑字的约定,转化成可追踪、可提醒、可升级的动作。有客户测算过,仅付款提醒自动化一项,每年减少财务部人工催款工时约240小时。
3. 完整部署与核心环节实现
3.1 数据库初始化:v1.3.sql脚本的关键设计与执行要点
v1.3.sql不是简单的一堆CREATE TABLE语句,而是按“基础数据→业务数据→系统数据”三阶段组织,且每阶段都有防错机制。第一阶段(1-100行)是基础表:
-- 创建数据库及用户(生产环境请勿直接执行,此处仅为示意)
CREATE DATABASE IF NOT EXISTS contract_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'contract_app'@'%' IDENTIFIED BY 'StrongPass123!';
GRANT SELECT,INSERT,UPDATE,DELETE ON contract_db.* TO 'contract_app'@'%';
FLUSH PRIVILEGES;
-- 建表前先删旧表(开发环境可用,生产环境注释掉)
-- DROP TABLE IF EXISTS partner;
-- DROP TABLE IF EXISTS contract;
这里特意把CREATE USER和GRANT分开写,是因为客户内网往往有严格的DBA审批流程,DBA只允许你提供CREATE TABLE语句,不允许你建用户——所以脚本里用注释标明哪些行要删掉。第二阶段(101-500行)是核心业务表,每张表都带COMMENT:
CREATE TABLE `contract` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`contract_no` varchar(50) NOT NULL COMMENT '合同编号,格式:HT-2024-0001',
`party_a_id` bigint NOT NULL COMMENT '甲方ID,关联partner.id',
`party_b_id` bigint NOT NULL COMMENT '乙方ID',
`amount_total` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '合同总金额',
`status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT/REVIEWING/EFFECTIVE/TERMINATED/ARCHIVED',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_contract_no` (`contract_no`),
KEY `idx_party_a` (`party_a_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='合同主表';
注意三个细节:① 所有varchar字段都指定了长度(避免MySQL默认5000导致索引失效);② status字段用ENUM替代VARCHAR,但这里用VARCHAR是为了方便后期扩展(比如加个“SUSPENDED”状态不用改表结构);③ 每个索引都带注释说明用途(idx_party_a用于“查甲方所有合同”,idx_status用于“查所有待生效合同”)。第三阶段(501-结尾)是初始化数据:
-- 插入默认审批流(部门经理→分管副总→总经理)
INSERT INTO approval_process (id, process_code, process_name, description)
VALUES (1, 'DEFAULT', '通用审批流', '适用于90%合同类型');
INSERT INTO approval_node (id, process_id, node_code, node_name, role_code, sort_order, rule_type)
VALUES
(1, 1, 'DEPT_MANAGER', '部门经理', 'ROLE_DEPT_MANAGER', 1, 'FIRST_APPROVE'),
(2, 1, 'VP_DEPT', '分管副总', 'ROLE_VP_DEPT', 2, 'FIRST_APPROVE'),
(3, 1, 'GM', '总经理', 'ROLE_GM', 3, 'FIRST_APPROVE');
执行要点:必须用mysql客户端带–default-character-set=utf8mb4参数执行,否则中文注释会乱码:
mysql -u root -p --default-character-set=utf8mb4 < v1.3.sql
如果客户用Navicat,要在连接属性里把“字符集”设为utf8mb4,否则执行到COMMENT时会报错。我们还在readme.txt里写了应急方案:如果执行失败,先用source命令分段执行,定位到哪一行报错(通常是客户MySQL版本低于5.7,不支持utf8mb4_0900_as_cs排序规则,此时把脚本里所有COLLATE utf8mb4_0900_as_cs改成COLLATE utf8mb4_unicode_ci即可)。
3.2 生产环境配置:application-prod.yml的参数详解与安全加固
application-prod.yml不是application.yml的简单复制,而是针对生产环境做了七层加固。第一层是数据库连接池:
spring:
datasource:
url: jdbc:mysql://mysql:3306/contract_db?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&serverTimezone=Asia/Shanghai&allowMultiQueries=true&useSSL=false
username: contract_app
password: StrongPass123!
hikari:
connection-timeout: 30000
max-lifetime: 1800000
idle-timeout: 600000
max-pool-size: 20
min-idle: 5
connection-test-query: SELECT 1
关键参数解读:max-pool-size=20不是拍脑袋定的,而是根据客户并发量公式计算:假设峰值并发用户300人,每人平均占用连接0.05秒(Spring Boot JPA查询平均耗时),则所需连接数=300×0.05=15,取整为20留余量。第二层是Redis配置:
redis:
host: redis
port: 6379
password: RedisPass456!
database: 0
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 0
max-wait: 10000
这里database=0是故意的,因为客户内网Redis往往只给一个库,我们不抢其他系统资源。第三层是OnlyOffice集成:
onlyoffice:
document-server-url: https://onlyoffice.yourdomain.com/
jwt-secret: YourJwtSecretKey123! # 必须与OnlyOffice配置一致
jwt-enabled: true
callback-url: https://contract.yourdomain.com/api/callback/onlyoffice
jwt-secret必须与OnlyOffice的/etc/onlyoffice/documentserver/default.json里”token”:{“inbox”:{“inbox”:”YourJwtSecretKey123!”}}完全一致,否则回调会被拒绝。第四层是XXL-JOB配置:
xxl:
job:
admin:
addresses: http://xxl-job-admin:8080/xxl-job-admin
executor:
appname: contract-executor
ip:
port: 9999
logpath: /app/logs/xxl-job/jobhandler
logretentiondays: 30
注意executor.port=9999是固定端口,因为XXL-JOB Admin要主动连这个端口注册,不能随机。第五层是Nginx反向代理前置:
server:
port: 8080
forward-headers-strategy: native
forward-headers-strategy: native是关键,它让Spring Boot信任X-Forwarded-For头,否则获取用户真实IP会变成127.0.0.1。第六层是日志脱敏:
logging:
level:
com.zcr.contract: INFO
org.springframework.web.servlet.DispatcherServlet: WARN
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
file:
name: /app/logs/application.log
把DispatcherServlet日志设为WARN,避免DEBUG级别打印海量请求参数(含敏感信息)。第七层是Actuator安全:
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,loggers
endpoint:
health:
show-details: when_authorized
只暴露必要端点,health详情需认证才可见。这些配置不是凭空而来,而是我们帮客户处理过27次生产事故后沉淀下来的——比如有次客户把max-pool-size设成100,结果MySQL连接数被打满,整个公司系统瘫痪;还有次jwt-secret不一致,导致OnlyOffice编辑后无法保存,法务部集体罢工。所以每一行配置,背后都是血泪教训。
3.3 Docker容器化部署:docker-compose.yml的逐行解析与调试技巧
docker-compose.yml是整套系统能否顺利落地的生命线,我们把它拆成五个服务块,每块都带注释和调试钩子:
version: '3.8'
services:
# ========== MySQL服务 ==========
mysql:
image: mysql:5.7.39
container_name: contract-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: RootPass789!
MYSQL_DATABASE: contract_db
MYSQL_USER: contract_app
MYSQL_PASSWORD: StrongPass123!
volumes:
- ./mysql/data:/var/lib/mysql
- ./mysql/conf/my.cnf:/etc/mysql/my.cnf
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pRootPass789!"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s
# ========== Redis服务 ==========
redis:
image: redis:6.2.6-alpine
container_name: contract-redis
restart: unless-stopped
command: redis-server /usr/local/etc/redis/redis.conf
volumes:
- ./redis/conf/redis.conf:/usr/local/etc/redis/redis.conf
- ./redis/data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "-h", "localhost", "ping"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s
# ========== XXL-JOB Admin服务 ==========
xxl-job-admin:
image: xuxueli/xxl-job-admin:2.3.1
container_name: contract-xxl-admin
restart: unless-stopped
environment:
PARAMS: "--spring.datasource.url=jdbc:mysql://mysql:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true --spring.datasource.username=root --spring.datasource.password=RootPass789!"
ports:
- "8080:8080"
depends_on:
mysql:
condition: service_healthy
# ========== OnlyOffice Document Server ==========
onlyoffice:
image: onlyoffice/documentserver:7.4.0
container_name: contract-onlyoffice
restart: unless-stopped
privileged: true
ports:
- "8081:80"
volumes:
- ./onlyoffice/data:/var/www/onlyoffice/Data
- ./onlyoffice/logs:/var/log/onlyoffice
- ./onlyoffice/cache:/var/www/onlyoffice/DocumentServer/cache
environment:
JWT_ENABLED: 'true'
JWT_INBOX_SECRET: 'YourJwtSecretKey123!'
JWT_OUTBOX_SECRET: 'YourJwtSecretKey123!'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/healthcheck"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
# ========== 合同后端服务 ==========
server1:
build: ./server1
container_name: contract-server
restart: unless-stopped
environment:
SPRING_PROFILES_ACTIVE: prod
TZ: Asia/Shanghai
ports:
- "8080:8080"
volumes:
- ./logs:/app/logs
- ./config/application-prod.yml:/app/config/application-prod.yml
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
xxl-job-admin:
condition: service_healthy
onlyoffice:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
部署调试技巧:
1. 分步启动法:不要一上来就docker-compose up -d。先启动mysql:docker-compose up -d mysql,然后docker-compose exec mysql mysql -uroot -pRootPass789! -e "SHOW DATABASES;"确认数据库创建成功;
2. 健康检查排查:如果某个服务healthcheck失败,用docker-compose ps看STATUS列,如果是unhealthy,执行docker-compose logs <service_name>看错误日志;
3. 网络连通性验证:在server1容器里执行ping mysql,确认DNS解析正常;再执行telnet mysql 3306,确认端口可达;
4. OnlyOffice特殊处理:首次启动onlyoffice服务后,必须访问http://your-ip:8081,等页面显示“Document Server is running”且右下角绿色图标亮起,才算真正就绪(它内部要初始化缓存和字体,耗时约90秒);
5. Nginx反向代理调试:如果前端访问502,先确认nginx容器是否启动:docker-compose ps nginx,再进容器docker-compose exec nginx nginx -t检查配置语法,最后docker-compose exec nginx tail -f /var/log/nginx/error.log看实时错误。这些技巧,都是我们在客户机房蹲点三天,挨个解决部署问题后总结出来的,比任何文档都管用。
3.4 Nginx反向代理配置:nginx.conf的精准路由与HTTPS强制跳转
nginx.conf不是简单把请求转发给server1,而是做了四层精细化路由。第一层是静态资源分离:
upstream backend {
server contract-server:8080;
}
server {
listen 80;
server_name contract.yourdomain.com;
# 强制HTTPS跳转(生产环境必须)
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name contract.yourdomain.com;
ssl_certificate /etc/nginx/ssl/contract.crt;
ssl_certificate_key /etc/nginx/ssl/contract.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
# ========== 静态资源直接由Nginx服务 ==========
location /static/ {
alias /usr/share/nginx/html/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# ========== OnlyOffice文档服务反向代理 ==========
location /onlyoffice/ {
proxy_pass https://contract-onlyoffice:443/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# OnlyOffice要求的特殊头
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Port 443;
}
# ========== 后端API接口代理 ==========
location /api/ {
proxy_pass http://backend/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 关键:传递真实客户端IP给Spring Boot
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Port 443;
# 超时设置,避免大文件上传中断
proxy_connect_timeout 300;
proxy_send_timeout 300;
proxy_read_timeout 300;
send_timeout 300;
}
# ========== 前端HTML入口 ==========
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}
关键点解析:
- /static/路由指向Nginx本地目录,这样js/css/image等静态资源不经过Java应用,直接由Nginx高速缓存,实测首屏加载提速40%;
- /onlyoffice/路由必须带proxy_set_header X-Forwarded-Host和X-Forwarded-Port,因为OnlyOffice回调时要用这两个头构造绝对URL,否则会回调到http://localhost:8080导致失败;
- /api/路由的proxy_read_timeout 300是为大合同Word文件上传准备的(最大支持100MB),避免Nginx中途断连;
- try_files $uri $uri/ /index.html是Vue2 SPA的标配,确保前端路由刷新不404。
HTTPS证书配置要点:证书文件必须放在容器内/etc/nginx/ssl/路径,且权限设为644(chmod 644 contract.crt contract.key),否则Nginx启动报错。我们还在readme.txt里写了免费证书获取指南:用acme.sh申请Let’s Encrypt证书,命令一行搞定:
acme.sh --issue -d contract.yourdomain.com --standalone -k ec-256
acme.sh --install-cert -d contract.yourdomain.com \
--cert-file /path/to/cert.crt \
--key-file /path/to/cert.key \
--fullchain-file /path/to/fullchain.cer
这样客户不用买商业证书,也能享受HTTPS加密。
4. 常见问题与排查技巧实录
4.1 OnlyOffice编辑器白屏/加载失败的五大原因与速查表
OnlyOffice白屏是部署中最高频的问题,我们整理了客户现场遇到的全部案例,按发生概率排序:
| 序号 | 现象 | 根本原因 | 排查命令 | 解决方案 |
|---|---|---|---|---|
| 1 | 编辑页空白,控制台报Failed to load resource: the server responded with a status of 404 () | Nginx未配置/onlyoffice/路由,或路径大小写错误(如写成/OnlyOffice/) | curl -I https://contract.yourdomain.com/onlyoffice/healthcheck | 检查nginx.conf中location块是否为/onlyoffice/(末尾斜杠不能少),确认server1容器里application-prod.yml的onlyoffice.document-server-url值与Nginx server_name一致 |
| 2 | 编辑器显示“Loading…”后卡死,Network面板看到converter请求502 | OnlyOffice容器未启动,或healthcheck失败 | docker-compose ps onlyoffice → 看STATUS是否healthy;docker-compose logs onlyoffice \| grep -i error | 先docker-compose up -d onlyoffice,等待2分钟,再docker-compose exec onlyoffice curl http://localhost:80/healthcheck,若返回{"status":"ok"}则OK |
| 3 | 编辑器能打开,但保存时报Error: Invalid token | JWT密钥不一致:server1的onlyoffice.jwt-secret与OnlyOffice的JWT_INBOX_SECRET不同 | docker-compose exec server1 cat /app/config/application-prod.yml \| grep jwt-secret;docker-compose exec onlyoffice cat /etc/onlyoffice/documentserver/default.json \| grep inbox | 两边密钥必须完全相同,包括大小写和特殊字符,改完重启onlyoffice和server1 |
| 4 | 编辑器打开后立即弹窗The document is being edited by another user | document_lock表里残留锁记录,或Redis连接失败导致锁未释放 | docker-compose exec redis redis-cli KEYS "doc_lock:*";docker-compose exec server1 curl http://localhost:8080/actuator/health | 若Redis有锁key,执行DEL doc_lock:xxx;若server1健康检查失败,查docker-compose logs server1看数据库/Redis连接日志 |
| 5 | 编辑器能保存,但合同详情页看不到新版本,或版本树为空 | callback_url未配置,或server1的/api/callback/onlyoffice接口被防火墙拦截 | docker-compose exec onlyoffice cat /etc/onlyoffice/documentserver/default.json \| grep callback;curl -X POST https://contract.yourdomain.com/api/callback/onlyoffice -d '{"key":"test","status":2}' | 确认callback_url值为https://contract.yourdomain.com/api/callback/onlyoffice(必须HTTPS),用curl模拟回调测试接口是否200 |
特别提醒:OnlyOffice 7.4.0有个隐藏坑——如果服务器时间不准(误差超过5分钟),JWT签名会验证失败,导致所有回调被拒。解决方案是容器启动时挂载宿主机时间:在docker-compose.yml的onlyoffice服务下加volumes: - /etc/localtime:/etc/localtime:ro。
4.2 合同审批流卡在某节点不动的排查路径
审批流卡顿是法务部投诉最多的问题,我们建立了标准化排查树:
第一步:确认是否真卡住
- 登录后台管理页,进“审批监控”菜单,查该合同的approval_instance记录,看current_node_code和status字段;
- 如果status=RUNNING但updated_time超过timeout_hours(如72小时),则是真卡住;
- 如果status=WAITING,说明流程还没走到这节点,去查上一节点的审批人是否已处理。
第二步:查XXL-JOB执行日志
- 访问https://contract.yourdomain.com/xxl-job-admin,用户名密码默认admin/123456;
- 进“调度中心”→“执行器管理”,确认contract-executor是否在线(状态为ONLINE);
- 进“任务管理”,找“审批节点超时检查”任务,看最近几次执行日志:
- 若日志为空,说明XXL-JOB Admin没连上MySQL,查docker-compose logs xxl-job-admin;
- 若日志显示No pending tasks found,说明数据库里没符合条件的记录,可能是审批人点了“同意”但网络中断,导致状态没更新——此时手动执行SQL:UPDATE approval_instance SET current_node_code='NEXT_NODE', status='RUNNING' WHERE id=xxx;
第三步:查Redis锁状态
- 审批流使用Redis分布式锁防止并发冲突,锁key格式为approval:lock:contract_id:12345;
- 执行docker-compose exec redis redis-cli GET "approval:lock:contract_id:12345",若返回nil,说明没锁;若返回”zhangsan”,说明张三正在审批;
- 如果张三已离职,锁又没自动释放(Redis key过期时间设为3600秒),执行DEL "approval:lock:contract_id:12345"强制解锁。
第四步:查消息队列积压(若启用)
- 系统预留了RabbitMQ接口(虽默认未启用),如果客户启用了,检查docker-compose exec rabbitmq rabbitmqctl list_queues,看是否有大量unacked消息;
- 若有,执行rabbitmqctl purge_queue contract_approval_queue清空队列,再重启server1。
我们把这套排查路径做成了后台管理页的“一键诊断”按钮,点击后自动执行上述四步检查,并生成HTML报告。有客户说,以前卡住要找IT、DBA、运维三方开会,现在点一下按钮,5分钟内定位到是Redis锁没释放,运维直接DEL就解决了。
4.3 Docker部署后Nginx 502 Bad Gateway的七种可能与验证方法
502错误本质是Nginx无法把请求转发给后端,原因千奇百怪,我们按验证难度从低到高排序:
- server1容器根本没启动:
docker-compose ps server1→ 看STATUS是否running;若为exited,docker-compose logs server1看启动报错(常见:application-prod.yml数据库密码错、Redis密码错); - server1健康检查失败:
curl http://localhost:8080/actuator/health→ 若返回DOWN,查docker-compose logs server1里DataSourceHealthIndicator和RedisHealthIndicator是否报错; - Nginx配置语法错误:
docker-compose exec nginx nginx -t→ 若报错,按提示行号修改nginx.conf; - Nginx与server1网络不通:
docker-compose exec nginx ping contract-server→ 若不通,检查docker-compose.yml里server1的container_name是否为contract-server(必须与nginx.conf里upstream的server名一致); - server1监听端口不对:
docker-compose exec server1 netstat -tuln \| grep 8080→ 若无输出,说明server1没监听8080,查docker-compose logs server1里是否打印Tomcat started on port(s): 8080 (http); - 防火墙拦截:
docker-compose exec nginx curl -v http://contract-server:8080/actuator/health→ 若超时,说明宿主机防火墙(如ufw)或云厂商安全组阻止了容器间通信; - SELinux阻止:CentOS/RHEL系统默认开启SELinux,会阻止容器间网络通信,执行
getenforce若返回Enforcing,则临时关闭:setenforce 0,永久关闭需改/etc/selinux/config。
最狠的验证法:在Nginx容器里直接curl后端,绕过所有代理逻辑:
docker-compose exec nginx curl -v http://contract-server:8080/api/health
如果这行命令返回200,说明后端没问题,问题一定出在Nginx配置;如果返回Connection refused,说明server1没启动或端口不对;如果返回timeout,说明网络层有问题。这个命令,我们教给客户运维后,90%的502问题都能自己搞定。
4.4 私有化部署中的电子签章对接经验与避坑指南
电子签章是客户最关心的扩展点,但也是最容易踩坑的模块。我们不推荐客户自己开发签章功能,而是提供标准化对接方案:
对接原则:
- 只对接签章平台的“签署”和“验签”两个API,不碰其用户体系、印章管理、审计日志;
- 合同签署前,系统生成PDF(用Apache PDFBox),调用签章平台API传入PDF Base64和签署位置坐标;
- 签署完成后,签章平台回调我们配置的webhook,传回签署后PDF下载URL;
- 系统下载PDF,存入OSS,更新合同状态为SIGNED,并记录sign_time和sign_platform字段。
避坑指南:
- 坐标系陷阱:不同签章平台PDF坐标原点不同(左上角/左下角),单位也不同(像素/磅/毫米)。我们封装了一个CoordinateConverter工具类,根据平台文档配置转换规则,比如某平台要求“Y坐标=页面高度-实际Y”,必须在调用API前转换;
- 时间戳同步:签章平台要求请求头带X-Timestamp,且与服务器时间误差<5分钟。我们强制server1容器使用宿主机时间(volumes: - /etc/localtime:/etc/localtime:ro),并在application-prod.yml里配置spring.jackson.date-format=yyyy-MM-dd HH:mm:ss;
- 回调幂等性:签章平台可能多次回调同一事件,我们在webhook接口里加了Redis分布式锁:SET sign_callback_lock:${contractId} ${uuid} EX 60 NX,锁存在才处理,避免重复下载PDF;
- 法律效力存证:签署后的PDF必须保留原始哈希值,我们用MessageDigest.getInstance("SHA-256").digest(pdfBytes)计算,并存入contract_sign_log表,供后续司法鉴定使用。
有客户曾因坐标系没转换,导致电子签名盖在PDF页眉上,被法院认定为无效签署。所以我们在readme.txt里用加粗字体强调:“对接前务必用测试PDF验证签署位置,坐标偏差超过5像素即需调整转换规则”。
5. 二次开发与定制化扩展指南
5.1 新增合同类型:从零开始配置采购合同与服务合同的全流程
客户常问:“我想加个‘采购合同’类型,和现有的‘销售合同’流程不一样,怎么搞?”答案是:不用改一行Java代码,全在后台配置完成。步骤如下:
第一步:定义合同类型元数据
进后台“系统管理”→“合同类型”,点击“新增”:
- 类型编码:PURCHASE(必须英文,全大写,用于代码识别)
- 类型名称:采购合同
- 模板路径:/templates/purchase.docx(提前把Word模板放到server1的resources/templates目录)
- 默认审批流:采购专用流(需先创建,见第二步)
- 是否启用电子签章:是
第二步:配置专属审批流
进“流程管理”→“审批流定义”,点击“新增流程”:
- 流程编码:PURCHASE_FLOW
- 流程名称:采购合同审批流
- 节点配置(拖拽排序):
1. 采购专员(角色:ROLE_PURCHASE_CLERK,规则:FIRST_APPROVE)
2. 技术部(角色:ROLE_TECH_DEPT,规则:ALL_APPROVE)
3. 财务部(角色:ROLE_FINANCE,规则:FIRST_APPROVE)
4. 分管副总(角色:ROLE_VP_PURCHASE,规则:FIRST_APPROVE)
第三步:配置模板变量
打开purchase.docx模板,按规范插入MERGEFIELD:
- { MERGEFIELD purchase_item }(采购物品清单,用表格形式)
- { MERGEFIELD delivery_date }(交货日期)
- { MERGEFIELD warranty_months }(质保月数)
第四步:配置履约计划规则
进“履约管理”→“计划模板”,新增:
- 模板名称:采购合同付款计划
- 触发条件:合同类型= PURCHASE
- 计划项:
- 第1期:预付款30%,计划日期=签约日+0天
- 第2期:到货款50%,计划日期=交货日期+0天(自动取模板中delivery_date值)
- 第3期:质保金20%,计划日期=交货日期+质保月数×30天
第五步:前端展示定制
修改vue组件src/views/contract/Create.vue,在合同类型下拉框change事件里:
handleContractTypeChange(val) {
if (val === 'PURCHASE') {
this.showPurchaseFields = true; // 显示采购专用字段
this.templateUrl = '/static/templates/purchase.docx';
}
}
然后在template里用v-if="showPurchaseFields"控制采购字段显隐。整个过程,前后端加起来不超过20分钟,且所有配置都存入数据库,升级系统时不会丢失。我们刻意避免在Java代码里写if (type.equals(“PURCHASE”)),就是为了把业务规则从代码里解放出来。
5.2 对接企业微信/钉钉:消息通知的三种集成模式
客户内网常用企微或钉钉发审批待办,我们提供了三种接入方式,按实施难度递增:
模式一:Webhook推送(最快,5分钟)
- 企微后台创建自定义机器人,复制Webhook地址;
- 在application-prod.yml里配置:
yaml wecom: webhook-url: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxx
- 修改notifyService.sendApprovalNotice()方法,在发送邮件后追加:
java String json = "{\"msgtype\": \"text\", \"text\": {\"content\": \"【合同审批】"+contract.getContractNo()+"待您审批,请登录系统处理。\"}}"; restTemplate.postForObject(wecomWebhookUrl, json, String.class);
- 优点:零依赖,纯HTTP调用;缺点:只能发文本,不能带跳转链接。
模式二:企微应用消息(推荐,30分钟)
- 企微后台创建“内部应用”,获取AgentId、Secret、CorpId;
- application-prod.yml配置:
yaml wecom: corp-id: wwxxxxx agent-id: 10001 secret: xxxxx token: xxxxx # 企微消息校验Token
- 后端用企微SDK发送图文消息,含合同编号、甲方名称、审批按钮(点击跳转合同详情页);
- 优点:用户体验好,支持按钮交互;缺点:需配置企微消息接收服务器(我们已内置,只需填Token)。
模式三:钉钉机器人+审批流打通(深度,2小时)
- 钉钉后台创建群机器人,获取Webhook;
- 更关键的是,用钉钉开放平台的“审批流”能力,把合同审批节点映射为钉钉审批单;
- 我们封装了DingTalkApprovalService,当合同进入某节点时,自动调用钉钉API创建审批实例,并把审批结果回调同步回合同系统;
- 优点:完全融入钉钉工作台,审批人不用切系统;缺点:需申请钉钉ISV资质,流程较长。
我们建议客户从模式一开始,验证通后再升级。有客户用模式一上线后,法务部反馈“现在手机一震就知道有审批,再也不用守着电脑等邮件了”。
5.3 性能优化实战:MySQL慢查询治理与Redis缓存策略
系统上线后,客户反馈“查合同列表很慢”,我们介入后发现是典型的慢查询问题。治理过程值得复刻:
第一步:定位慢SQL
- 开启MySQL慢查询日志:在my.cnf里加slow_query_log=ON、long_query_time=1;
- 查看日志:tail -f /var/lib/mysql/slow.log,发现高频慢SQL:
sql SELECT * FROM contract WHERE status IN ('EFFECTIVE','ARCHIVED') ORDER BY sign_date DESC LIMIT 20;
执行时间12.3秒。
第二步:分析执行计划
- 执行EXPLAIN SELECT * FROM contract WHERE status IN ('EFFECTIVE','ARCHIVED') ORDER BY sign_date DESC LIMIT 20;
- 结果显示type=ALL(全表扫描),key=NULL,rows=285632(表里28万条合同)。
第三步:建立复合索引
- 原有索引只有idx_status(status单列),无法支撑status+sign_date联合查询;
- 执行ALTER TABLE contract ADD INDEX idx_status_sign_date (status, sign_date);
- 再EXPLAIN,type=range,key=idx_status_sign_date,rows=1256,查询降至0.08秒。
第四步:引入Redis缓存
- 对高频查询“按甲方查所有合同”,在ContractService里加缓存:
java @Cacheable(value = "contractsByPartyA", key = "#partyAId + '_' + #page + '_' + #size") public Page<Contract> findByPartyA(Long partyAId, Pageable pageable) { return contractMapper.selectByPartyA(partyAId, pageable); }
- 缓存失效策略:当合同状态变更(如从EFFECTIVE变ARCHIVED)时,用@CacheEvict清除相关缓存。
第五步:前端分页优化
- 原来用LIMIT 0,20,大数据量时性能差;改为游标分页:
sql SELECT * FROM contract WHERE status IN ('EFFECTIVE','ARCHIVED') AND id < #{lastId} ORDER BY id DESC LIMIT 20;
用上一页最后一条记录的id作为下一页起点,避免OFFSET偏移。
这套组合拳后,合同列表首屏加载从12秒降到320ms,客户说“终于能流畅滚动了”。性能优化没有银弹,就是一行SQL、一个索引、一段缓存代码的累积。
我在实际部署中发现,最常被忽略的是MySQL的innodb_buffer_pool_size参数。很多客户用默认值128MB,而他们的合同表数据量超2GB,导致90%的查询都要磁盘IO。我们会在readme.txt里明确写出:“请将innodb_buffer_pool_size设为物理内存的70%,例如16G内存服务器,设为12G”。这种细节,才是决定系统能否长期稳定运行的关键。
简介:一套可直接部署的合同电子化管理解决方案,后端用Spring Boot(Java),前端基于Vue2,完整支撑合同相对方管理、模板配置、在线起草、多级审批、履约监控、归档检索等环节。内置v1.3.sql建库脚本,适配MySQL;提供application-prod.yml生产环境配置,支持Nginx反向代理和Redis缓存;包含docker-compose.yml及Dockerfile,便于容器化部署;集成XXL-JOB实现合同到期提醒、履约节点自动触发等定时任务;对接OnlyOffice实现合同文档在线协同编辑与版本留痕;前端静态资源打包为html.zip,后端服务代码独立存放于server1目录;附带product.jpg界面预览图、LICENSE授权说明和readme.txt操作指引;系统设计兼容企业内网环境,预留电子签章接口,满足私有化部署与二次开发需求。
208

被折叠的 条评论
为什么被折叠?



