夜雨聆风学习资料网

ARTICLE · 1319079

OnlyOffice+poi-tl:把 Word 模板变成可填写、可生成的合同系统

OnlyOffice+poi-tl:把 Word 模板变成可填写、可生成的合同系统

OnlyOffice+poi-tl:把 Word 模板变成可填写、可生成的合同系统

客户选好了,合同金额填好了,服务日期也填好了。接下来打开 Word,却还要把这些内容再改一遍:替换公司名称、核对金额大小写、修改期限,最后另存一个文件再上传。

这段重复劳动,很适合交给业务系统完成。经办人继续填写熟悉的合同表单,Word 保留原来的排版;需要逐份变化的内容从单据取值,特殊约定再在线补充。

RuoYi Office 的合同模块把这条路径拆成了两部分:poi-tl 负责用数据生成 DOCX,OnlyOffice 负责在浏览器里打开和编辑文档。 两者之间,还要连接模板配置、业务单据和文件回存。

下面用 2026 年 10 月的一次合同教学实操,把这条链路走完整。案例是一份年度技术服务合同,所有单位和条款均为教学内容。

先看效果:同一份表单,生成可以继续编辑的 Word

这次操作使用一个技术服务模板,填入合同名称、我方主体、对方单位、十万元金额以及服务期限,再补充本次服务范围。生成后的正文如下。

▲ 原有标题和段落继续保留,名称、主体、服务范围、期限和金额已经成为这份合同的具体内容。图为教学环境的完整文档页面。

这里有几个容易被忽略的产品细节。

合同名称、主体和金额不需要在“合同填写项”中再输入一次,它们来自单据已有字段;服务范围因合同而异,保留给经办人填写;生成后的文件仍然是 DOCX,可以继续打开编辑。

这次案例实际核对了七个字段和金额大写,随后在正文末尾补充约定,关闭后从同一份合同重新打开。生成、编辑、重开都围绕同一张草稿进行,没有为了演示反复创建合同。

它让表单数据进入正式文档,也让文档继续留在业务记录之中。 如果只做到下载一个文件,后续修改仍然散落在个人电脑里,这条链路就只完成了一半。

模板只配一次,但要把“从哪里取值”说明白

管理员进入合同模板,上传 DOCX 源文件,设置合同类型、分类和生成后的正文是否允许二次修改。本例选择允许修改,以便演示单份合同的补充约定。

模板正文仍按 Word 的方式编排。固定条款正常书写,变化部分放入占位符,例如 {{contractName}}、{{totalAmount}}。标记中的 key 要与字段定义一致。

▲ 模板保存固定条款和占位符。系统菜单、配置入口与编辑器完整保留;名称和服务范围此时还没有被替换成某一份合同的数据。

接着打开“占位符定义”,登记每个 key 的类型与来源。界面中的显示名称帮助经办人理解用途,真正参与填充的是 key、取值来源和绑定字段。

▲ 画面展示自动取值的六项配置;服务范围另设为手动填写,共同构成本例的七个字段。

模板 key
取值方式
本例对应内容
contractName
单据字段
年度技术服务合同名称
counterpartyName
单据字段
教学对方单位名称
ourCompanyName
单据字段
我方签约主体名称
totalAmount
单据字段
100000 元
startDate
、endDate
单据字段
本次服务起止日期
serviceScope
手动填写
配置指导、培训及维护支持

这样分配之后,经办人选择模板时,重复内容由系统读取,只需要关注本份合同的差异项。

▲ 服务范围是需要经办人确认的变化项。选中模板之后还要执行生成,不能把“已经选用”当作“正文文件已经更新”。

“生成合同文件”也不是一个纯浏览器动作。当前业务服务先保存合同信息,再调用文档生成服务。这样,后端读取的金额、主体和填写项有明确的单据归属,生成结果也能回写到同一份合同。

这里还有一个实际遇到的细节:教学联调时,主体选择组件显示了公司名称,但 ourCompanyName 没有正确回填。调整教学环境的回填逻辑后,生成正文才包含正确主体。上面的画面来自调整后的教学环境。

这类问题不能只盯着模板引擎排查。页面显示了某个值,不代表保存给后端的字段也有这个值。 遇到空白占位符,先沿“选择组件—表单字段—保存参数—生成模型”查看数据,比直接修改 Word 更容易找到原因。

选过模板以后又改了金额,生成时取哪一次的值?

这是文档生成中一个很实用的设计问题。

假设经办人先填金额,再选模板,之后又调整金额。如果生成逻辑一直使用“选模板那一刻”保存的填写项,正文就可能带入旧金额。

当前实现把来源分清后处理:先读已经保存的填写项,再把 source=form 的占位符按当前单据重新取值。手动填写的服务范围继续保留,绑定金额则以生成时的业务字段为准。

下面是字段解析中的关键片段,省略了空对象检查和字符串转换等辅助处理。

Map<String, Object> raw = new HashMap<>();Object fieldValues = contractInfo.getFieldValuesJson();if (fieldValues instanceof Map) {    ((Map<String, Object>) fieldValues).forEach(raw::put);}Map<String, Object> formSource = buildFormSourceMap(contractInfo);List<Map<String, Object>> fields =        resolveTemplateFields(contractInfo.getTemplateId());for (Map<String, Object> field : fields) {    if (field == null || field.get("key") == null) continue;    Object key = field.get("key");    String source = field.get("source") == null            ? null : String.valueOf(field.get("source"));    if ("form".equals(source)) {        Object bindField = field.get("bindField");        if (bindField != null && formSource.containsKey(String.valueOf(bindField))) {            raw.put(String.valueOf(key), formSource.get(String.valueOf(bindField)));        }    }}formSource.forEach(raw::putIfAbsent);

这个优先级需要明确写进设计。否则,同一个字段一会儿从表单来、一会儿从模板默认值来,生成错误时很难解释。

另外,重新读取字段发生在生成动作里,不意味着已经生成的 Word 会随着表单变化自动更新。 修改金额后,需要有意识地重新生成并核对正文;如果正文已有人工补充,还要先判断是否保留这些内容。

从数据关系看,模板定义、填写项和正文地址分别保存。

▲ 一份模板可以被多份合同引用。合同自己的填写项和正文地址,与模板源文件分开存储;模板配置版本也不等同于 DOCX 文件历史。

设计决定
当前处理
解决的问题
已有业务字段是否重复录入
绑定单据字段,生成时读取
减少两处维护不一致
合同差异怎么表达
保存手动填写项
不把所有内容写死在模板里
单份正文放在哪里
保存到合同正文地址
避免改一份合同影响通用模板
金额大写怎么生成
使用统一转换方法与公式
不让经办人维护两份金额文本

poi-tl 负责填充,OnlyOffice 负责编辑,两条路径不要混在一起

在这个实现中,正文生成不需要模拟用户在 OnlyOffice 里逐字输入。后端读取 DOCX 模板,组织数据模型,再交给 poi-tl 渲染。

项目还对模板做了少量扩展。例如金额大写可以写成 {{amount2CN(totalAmount)}},由项目自己的公式解析器处理;这不是把任意表达式直接当代码执行。

生成核心如下,省略了模板检查、表格循环绑定和异常处理。config 与 values 在前面的步骤中准备完成。

try (ByteArrayInputStream in = new ByteArrayInputStream(templateBytes);     ByteArrayOutputStream out = new ByteArrayOutputStream();     XWPFTemplate template = XWPFTemplate.compile(in, config).render(values)) {    template.write(out);    byte[] docxBytes = out.toByteArray();    String fileName = StrUtil.blankToDefault(            contractInfo.getContractName(),            "合同_" + contractInfo.getId())            + "_" + System.currentTimeMillis() + ".docx";    String url = stripQuery(fileApi.createFile(            docxBytes, fileName, "contract",            "application/vnd.openxmlformats-officedocument.wordprocessingml.document"));    return url;}

排版以 DOCX 模板为基础,这比在 Java 里逐段拼合同样式更便于业务人员维护。但“生成成功”仍然只表示产物创建完成,不能替代内容核对。本例实际检查了名称、双方主体、日期、金额大小写和服务范围,还检查了是否残留占位符。

复杂模板要继续测试表格和分页。当前代码对合同明细、收付款计划使用表格行循环,并检查循环标记是否位于表格单元格内;循环行的纵向合并也有专门处理。这说明文档结构本身会影响渲染结果。 本文七字段案例没有覆盖所有复杂模板,不能据此承诺任何 Word 文件都能原样填充。

文件生成后,OnlyOffice 才接手在线查看和编辑。它接收文件地址及编辑配置,经办人可以在允许编辑的正文中补充这一次合同的约定。

修改后能重新打开,保存链路才算走完

教学实操在正文末尾补充了一条服务响应约定,执行保存并关闭编辑页面,随后从同一合同的业务入口重新打开。补充文字仍然存在;对照模板源文件,它没有被写回通用模板。

▲ 页面底部的补充说明来自单份正文编辑。它属于这张合同,不应成为所有后续合同的默认条款。

这里的保存包含一次服务端回调。项目处理回调时,先校验令牌,再按状态决定是否回存,最后根据业务类型更新模板或合同。

public void handleCallback(String token, Map<String, Object> body) {    Map<String, Object> payload = verifyToken(token);    Integer status = MapUtil.getInt(body, "status");    if (status == null || (status != 2 && status != 6)) {        return;    }    String editedUrl = MapUtil.getStr(body, "url");    if (StrUtil.isBlank(editedUrl)) {        throw new IllegalArgumentException("回调缺少文件地址");    }    // 下载编辑后的文件、生成新文件名并回存,得到 newUrl    // ...    String bizType = MapUtil.getStr(payload, "bizType");    Long bizId = MapUtil.getLong(payload, "bizId");    if (BIZ_TYPE_TEMPLATE.equals(bizType) && bizId != null) {        templateService.updateTemplateDocFileUrl(bizId, newUrl);        // 模板首页预览更新省略    } else if (BIZ_TYPE_CONTRACT.equals(bizType) && bizId != null) {        contractInfoService.updateContractDocUrl(bizId, newUrl);    }}

代码中的 2 和 6 对应项目处理的可保存、强制保存状态。它们来自文档服务回调,不是浏览器页面上的合同审批状态。回存之后,业务记录持有新的正文地址,重新打开才能读到这次编辑的产物。

▲ 生成和编辑是两段链路。前一段把业务数据写进 DOCX,后一段把编辑后的文件重新关联到合同。

排查“编辑后还是旧内容”时,可以按这条路径逐段看:文档服务是否回调到了正确的业务环境,业务后端是否成功存入文件,合同正文地址是否已经更新,重新打开时是否仍使用旧地址。

这次教学环境就校正过回调端口,避免文件保存请求落到另一套业务服务。当前代码生成及回存时也使用新文件名,编辑器配置另有文档 key 的处理。文件地址和编辑会话都需要核对,不能把所有缓存问题归结为“刷新一下浏览器”。

还有一个更隐蔽的问题:正文在独立页面修改了,原合同表单可能仍拿着旧地址。此时再点表单保存,如果把旧地址原样交回后端,就可能覆盖新的正文引用。

前端因此记录正文是否在当前表单发生过变化。没有本地改动时,提交数据不携带正文内容和地址,让后端保留在线编辑后的结果。

const contentDirty = ref(false);function applyContentDirtyRule<T extends Record<string, any>>(data: T): T {  if (!contentDirty.value) {    delete data.contentHtml;    delete data.contractDocUrl;  }  return data;}

这段处理看起来比引入编辑器更小,却直接影响经办人能否放心地在“表单”和“正文”之间来回切换。

模板版本与正文版本,要分开理解

合同记录里保存了 templateId 和 templateVersion,模板配置也有版本记录。但这些字段不能直接推导出“任意一份旧合同,都能用当时的 Word 完整重现”。

当前模板版本表保存字段定义和富文本内容等信息,没有独立的 DOCX 源文件地址字段;生成 DOCX 时,代码按模板 ID 读取当前模板。模板配置留痕、Word 文件回存、单份正文修改,是三件不同的事。

如果要进一步做严格的历史重现,就需要把当时的模板文件、字段定义和业务取值一起固定下来。这是后续设计方向,不是本文已经验证的能力。

同样,表单金额改了不代表旧正文自动变化,模板条款更新也不代表已生成合同自动同步。自动化最有价值的地方,是减少可确定的重复录入;对一份已经人工补充过的正文,重新生成应该是明确的业务决定。

把这几个边界处理好,Word 模板才能成为可复用的业务工具:管理员维护固定内容和字段来源,经办人确认差异项,系统生成正文并保存编辑结果。下一次再做一份同类合同,重复输入少了,而每一份合同的具体内容仍然说得清、找得到。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料