Vue 3 项目实战:集成e签宝实现电子合同在线签署全流程
1. 为什么要在Vue 3项目里集成电子合同签署如果你正在开发一个SaaS平台、一个招聘系统或者任何需要用户在线确认协议、合同的Web应用那你肯定遇到过这个需求怎么让用户方便、安全、合法地在线签字过去我们可能需要用户打印、签字、扫描、再上传流程繁琐体验极差。现在电子合同签署已经成为标配它不仅能提升用户体验还能让业务流程完全线上化效率提升不是一点半点。在众多电子签名服务商里e签宝是国内比较主流的一个选择它提供了完整的API和SDK让开发者可以相对轻松地把签署能力集成到自己的应用里。但是官方文档往往比较庞杂对于刚接触的开发者来说可能会觉得无从下手。我自己在项目里踩过不少坑比如前端iframe加载的跨域问题、后端签名流程的时序、以及如何优雅地处理签署后的回调通知。这篇文章我就想把我从零开始在一个全新的Vue 3项目中成功集成e签宝电子合同签署功能的完整流程掰开揉碎了讲给你听。我会假设你是一个有Vue 3基础但对e签宝不太熟悉的开发者咱们一起手把手走一遍这个“端到端”的集成过程。整个流程的核心思路其实很清晰前端负责展示和交互后端负责与e签宝API“秘密对话”最后通过一个iframe把e签宝官方提供的、安全合规的签署页面嵌入到我们自己的应用里。用户在我们自己的网站里就能完成整个签署感觉不到跳转体验非常流畅。下面我们就从最开始的准备工作说起。2. 集成前的核心准备工作账号、密钥与合同模板在写第一行代码之前有几件“后勤”工作必须做完这直接决定了后续开发能否顺利进行。很多人一上来就闷头写代码结果写到一半发现密钥不对或者合同模板没创建又得返工非常浪费时间。2.1 注册与获取关键凭证首先你需要去e签宝官网注册一个开发者账号。通常他们会提供沙箱测试环境和生产环境。对于开发阶段我们全程使用沙箱环境就足够了所有功能都可用而且不会产生真实费用。注册成功后进入控制台你需要找到并记下以下几样东西它们就像你应用访问e签宝服务的“身份证”和“门禁卡”AppId 你的应用唯一标识。AppSecret或API Key/Secret 用于接口调用的密钥对。这是最核心的敏感信息必须妥善保管且只能用于后端服务绝不能暴露在前端代码中。我见过有开发者图省事直接把密钥写在前端环境变量里这是极其危险的做法。ProjectId项目ID 有些接口调用需要指定项目ID。把这些信息保存好我们会在后面的后端服务配置里用到。e签宝的API认证通常使用HMAC-SHA256签名算法或者简单的Bearer Token用AppId和AppSecret换取。我们示例中为了简化会使用类似Bearer Token的方式但实际生产环境请务必严格按照官方最新的签名规范来。2.2 在e签宝后台创建合同模板我们的场景是用户在线签署一份固定的合同比如《用户服务协议》。一种高效的方式是先在e签宝后台创建一个合同模板。登录e签宝沙箱后台找到合同模板管理。你可以上传一份准备好的PDF合同文件。上传后最关键的一步是在PDF上定义签署区域。你可以拖拽添加“签署区”组件并绑定一个“签署角色”比如“平台方”和“用户”。你需要记录下这个模板的templateId。为什么用模板因为这样后端API调用时只需要指定templateId和填充的变量比如用户名、日期e签宝就能自动生成一份包含正确签署位置的标准合同无需每次手动处理PDF坐标非常方便。当然你也可以选择“关键字定位”或者“自由签署”模式但模板化是管理固定格式合同的最佳实践。准备工作做完我们的“弹药”就齐了。接下来让我们从搭建项目骨架开始。3. 搭建Vue 3项目骨架与基础组件我们从头创建一个干净的Vue 3项目并集成一些必要的UI库为后续的签署页面做准备。3.1 使用Vite快速初始化项目打开终端执行以下命令。我习惯用Vite因为它速度飞快。npm create vuelatest my-e-sign-demo创建过程中你可以根据提示选择需要的特性。为了简单起见我们暂时不选Router和Pinia但可以加上TypeScript和ESLint。创建完成后进入项目目录并安装基础依赖。cd my-e-sign-demo npm install接下来我们安装一个UI组件库。这里我选用Element Plus因为它和Vue 3集成得很好组件丰富能帮我们快速搭建界面。当然你用Ant Design Vue、Naive UI也完全没问题原理是相通的。npm install element-plus element-plus/icons-vue安装好后在main.ts或main.js中全局引入Element Plus。import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue const app createApp(App) app.use(ElementPlus) app.mount(#app)3.2 创建合同签署页面组件在src/components目录下我们创建一个核心组件ContractSign.vue。这个组件将承载合同展示和签署触发的主要逻辑。我们先来搭建一个最基础的静态界面包含一个合同选择下拉框和一个用于显示签署页面的iframe区域。template div classcontract-sign-container h2电子合同签署/h2 el-card classbox-card template #header div classcard-header span请选择待签署的合同/span /div /template !-- 合同选择器 -- div classselect-area el-select v-modelselectedContractId placeholder请选择合同 stylewidth: 300px changeonContractChange el-option v-forcontract in contractList :keycontract.id :labelcontract.name :valuecontract.id / /el-select el-button typeprimary :loadingloading clickhandlePrepareSign 发起签署 /el-button /div !-- 合同预览/签署区域 -- div v-ifsignUrl classsign-iframe-container div classiframe-header span请仔细阅读合同内容并在下方完成签署/span el-button typesuccess sizesmall clickhandleRefreshIframe刷新页面/el-button /div !-- 核心嵌入e签宝签署页面的iframe -- iframe :srcsignUrl refsignIframeRef width100% height650px frameborder0 title电子合同签署页面 /iframe /div div v-else classempty-tip el-empty description请先选择合同并点击【发起签署】 / /div /el-card !-- 状态提示 -- div v-ifstatusMessage classstatus-message el-alert :titlestatusMessage :typestatusType show-icon / /div /div /template script setup langts import { ref, onMounted } from vue import type { ContractItem } from ./types // 假设有类型定义 // 模拟的合同列表实际应从后端API获取 const contractList refContractItem[]([ { id: contract_001, name: 用户服务协议个人版 }, { id: contract_002, name: 平台入驻协议企业版 }, { id: contract_003, name: 数据保密协议 }, ]) const selectedContractId ref() const signUrl ref() // 存储从后端获取的e签宝签署页面URL const loading ref(false) const statusMessage ref() const statusType refsuccess | warning | info | error(info) const signIframeRef refHTMLIFrameElement() // 合同选择变化 const onContractChange (val: string) { console.log(选中合同:, val) signUrl.value // 切换合同时清空之前的签署页面 } // 发起签署这是关键步骤向后端请求签署链接 const handlePrepareSign async () { if (!selectedContractId.value) { statusMessage.value 请先选择一份合同 statusType.value warning return } loading.value true statusMessage.value 正在生成签署链接... statusType.value info try { // 调用我们自己的后端接口 const response await fetch(/api/sign/start?contractId${selectedContractId.value}) const result await response.json() if (result.code 200 result.data?.signUrl) { signUrl.value result.data.signUrl statusMessage.value 签署页面已加载请完成下方操作。 statusType.value success // 可以在这里添加一些监听iframe加载完成或签署成功的逻辑 } else { statusMessage.value 获取签署链接失败${result.message || 未知错误} statusType.value error } } catch (error: any) { statusMessage.value 网络请求异常${error.message} statusType.value error console.error(发起签署失败:, error) } finally { loading.value false } } // 刷新iframe用于调试或重试 const handleRefreshIframe () { if (signIframeRef.value signUrl.value) { signIframeRef.value.src signUrl.value } } onMounted(() { // 组件挂载时可以尝试从本地存储恢复上次选中的合同 const savedId localStorage.getItem(lastSelectedContractId) if (savedId) { selectedContractId.value savedId } }) /script style scoped .contract-sign-container { padding: 20px; max-width: 1200px; margin: 0 auto; } .select-area { display: flex; gap: 15px; align-items: center; margin-bottom: 20px; } .sign-iframe-container { margin-top: 25px; border: 1px solid #e4e7ed; border-radius: 4px; overflow: hidden; } .iframe-header { background-color: #f5f7fa; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e4e7ed; } .empty-tip { margin: 40px 0; text-align: center; } .status-message { margin-top: 20px; } /style这个组件已经具备了基础功能选择合同、点击按钮向后端请求签署链接、并将链接加载到iframe中。但真正的魔法发生在后端。前端只是负责“要链接”和“展示页面”。4. 后端服务搭建与核心接口实现前端页面准备好了现在我们需要一个“中间人”服务。这个服务有两个核心职责1. 接收前端的请求2. 去和e签宝的API“打交道”拿到真正的签署链接。绝对不能让前端直接调用e签宝API因为那会暴露你的密钥。这里我使用Node.js Express来演示后端逻辑因为它简单直观。你可以用你熟悉的任何后端语言Java, Go, Python等实现原理完全一样。4.1 初始化Node.js后端项目在你的Vue项目同级或另一个目录下新建一个server文件夹初始化一个Node项目。mkdir e-sign-server cd e-sign-server npm init -y npm install express axios dotenv cors安装的依赖中express是Web框架axios用于向后端发起HTTP请求dotenv用于管理环境变量存放我们的AppSecret等敏感信息cors用于处理跨域请求因为前端和后端可能在不同端口。4.2 实现生成签署链接的核心接口在server目录下创建app.js或index.js作为入口文件。我们先创建一个.env文件来存放密钥切记将此文件加入.gitignore。.env文件内容E_SIGN_APP_IDyour_sandbox_app_id E_SIGN_APP_SECRETyour_sandbox_app_secret E_SIGN_API_HOSThttps://smlopenapi.esign.cn # 沙箱环境域名生产环境不同下面是核心的后端服务代码const express require(express); const axios require(axios); const cors require(cors); require(dotenv).config(); const app express(); const PORT 3001; // 后端服务端口 // 中间件 app.use(cors()); // 允许前端跨域访问 app.use(express.json()); // 从环境变量读取配置 const { E_SIGN_APP_ID, E_SIGN_APP_SECRET, E_SIGN_API_HOST } process.env; // 一个简单的内存缓存用于存储token避免频繁获取 let cachedToken { value: null, expireTime: 0, }; /** * 1. 获取e签宝API访问令牌 (Token) * 这是调用大多数e签宝API的第一步 */ async function getESignToken() { const now Date.now(); // 如果缓存有效直接返回 if (cachedToken.value cachedToken.expireTime now 60000) { // 提前1分钟过期 console.log(使用缓存的Token); return cachedToken.value; } const url ${E_SIGN_API_HOST}/v1/oauth2/access_token; try { const response await axios.post(url, { appId: E_SIGN_APP_ID, appSecret: E_SIGN_APP_SECRET, grantType: client_credentials, }, { headers: { Content-Type: application/json } }); if (response.data.code 0) { const token response.data.data.token; const expiresIn response.data.data.expiresIn; // 有效期单位秒 cachedToken.value token; cachedToken.expireTime now expiresIn * 1000; // 转换为毫秒 console.log(获取新的Token成功); return token; } else { throw new Error(获取Token失败: ${response.data.message}); } } catch (error) { console.error(获取Token接口异常:, error.message); throw error; } } /** * 2. 核心接口根据合同模板和签署人信息创建签署流程并返回签署链接 */ app.post(/api/sign/start, async (req, res) { const { contractTemplateId, signerName, signerIdCardNo } req.body; // 从前端获取签署人信息 // 实际项目中这些信息应该从数据库或会话中安全获取 const flowName 签署流程_${new Date().getTime()}; try { // 步骤1: 获取Token const token await getESignToken(); // 步骤2: 创建签署流程 (Sign Flow) const createFlowUrl ${E_SIGN_API_HOST}/v1/signflows; const createFlowResp await axios.post(createFlowUrl, { autoArchive: true, businessScene: 业务场景${flowName}, configInfo: { noticeDeveloperUrl: https://your-domain.com/api/sign/callback, // 你的回调通知地址用于接收签署状态 redirectUrl: https://your-domain.com/sign/success, // 签署完成后跳转回你的页面 }, signFlowConfig: { signFlowTitle: flowName, }, }, { headers: { Content-Type: application/json, X-Tsign-Open-App-Id: E_SIGN_APP_ID, X-Tsign-Open-Token: token, } }); if (createFlowResp.data.code ! 0) { return res.status(500).json({ code: -1, message: 创建签署流程失败: ${createFlowResp.data.message} }); } const flowId createFlowResp.data.data.signFlowId; console.log(签署流程创建成功flowId: ${flowId}); // 步骤3: 添加签署方和签署区 (这里以个人签署为例) // 3.1 添加签署人 const addSignerUrl ${E_SIGN_API_HOST}/v1/signflows/${flowId}/signers; const signerResp await axios.post(addSignerUrl, { signerAccount: { signerAccountId: signerIdCardNo, // 通常用身份证号或自定义ID标识签署人 authorizedAccountId: E_SIGN_APP_ID, }, signOrder: 1, signerType: PERSONAL, signerName: signerName, }, { headers: { Content-Type: application/json, X-Tsign-Open-App-Id: E_SIGN_APP_ID, X-Tsign-Open-Token: token, } }); // 3.2 添加文档基于模板生成 const addDocUrl ${E_SIGN_API_HOST}/v1/signflows/${flowId}/documents; const docResp await axios.post(addDocUrl, { docs: [{ fileId: contractTemplateId, // 这里可以是模板ID也可以是上传文件后返回的fileId fileName: 服务协议.pdf, }] }, { headers: { Content-Type: application/json, X-Tsign-Open-App-Id: E_SIGN_APP_ID, X-Tsign-Open-Token: token, } }); // 3.3 为签署人添加签署区位置信息在模板中已定义这里关联即可 const addSignFieldUrl ${E_SIGN_API_HOST}/v1/signflows/${flowId}/signfields; // 这里需要根据模板中定义的签署组件ID进行关联是一个简化示例 const signFieldResp await axios.post(addSignFieldUrl, { signfields: [{ actorIndentityType: PERSONAL, fileId: contractTemplateId, sealType: PERSONAL, signFieldComponents: [{ componentKey: signature_1, // 与模板中定义的签署区组件key对应 componentType: SIGNATURE, }], signerAccountId: signerIdCardNo, }] }, { headers: { Content-Type: application/json, X-Tsign-Open-App-Id: E_SIGN_APP_ID, X-Tsign-Open-Token: token, } }); // 步骤4: 发起签署流程并获取签署链接 const startFlowUrl ${E_SIGN_API_HOST}/v1/signflows/${flowId}/start; const startResp await axios.put(startFlowUrl, {}, { headers: { Content-Type: application/json, X-Tsign-Open-App-Id: E_SIGN_APP_ID, X-Tsign-Open-Token: token, } }); // 步骤5: 获取签署人的签署页面链接短链接 const signUrlResp await axios.get(${E_SIGN_API_HOST}/v1/signflows/${flowId}/executeUrl?accountId${signerIdCardNo}, { headers: { X-Tsign-Open-App-Id: E_SIGN_APP_ID, X-Tsign-Open-Token: token, } }); if (signUrlResp.data.code 0) { const shortUrl signUrlResp.data.data.shortUrl; console.log(签署短链接生成成功: ${shortUrl}); // 返回给前端 return res.json({ code: 200, message: success, data: { signUrl: shortUrl, flowId: flowId, // 可以返回flowId方便后续查询状态 } }); } else { throw new Error(获取签署链接失败: ${signUrlResp.data.message}); } } catch (error) { console.error(生成签署链接全过程异常:, error.response?.data || error.message); return res.status(500).json({ code: -1, message: 服务端处理失败: ${error.message}, }); } }); /** * 3. 查询签署流程状态可选用于前端轮询或回调验证 */ app.get(/api/sign/status/:flowId, async (req, res) { const { flowId } req.params; try { const token await getESignToken(); const statusUrl ${E_SIGN_API_HOST}/v1/signflows/${flowId}; const statusResp await axios.get(statusUrl, { headers: { X-Tsign-Open-App-Id: E_SIGN_APP_ID, X-Tsign-Open-Token: token, } }); res.json(statusResp.data); } catch (error) { res.status(500).json({ code: -1, message: error.message }); } }); app.listen(PORT, () { console.log(后端服务运行在 http://localhost:${PORT}); console.log(请确保已正确配置 .env 文件中的 e签宝 密钥); });这段代码看起来有点长但它清晰地展示了与e签宝集成的标准流程获取Token - 创建签署流程 - 添加签署人和文档 - 添加签署区 - 发起流程 - 获取签署短链接。每个步骤都对应e签宝开放平台的一个API。你需要根据实际的业务需求比如企业签、多人会签、关键字定位签署等调整请求参数。4.3 处理签署完成回调为了让我们的系统知道用户何时签署完成e签宝支持通过回调通知noticeDeveloperUrl。你需要在公网可访问的服务器上提供一个API端点并在e签宝后台配置。当签署状态变更如完成、拒签、过期时e签宝会POST一个JSON消息到你的这个接口。你需要在接口中验证签名确保通知来自e签宝然后更新你自己数据库中的合同状态。这是一个保证数据最终一致性的重要环节。5. 前后端联调与关键问题排查现在我们有了一个能跑的前端Vue 3和一个能跑的后端Node。让它们联动起来。5.1 配置开发环境代理在Vue项目根目录的vite.config.js中配置代理解决开发时的跨域问题。import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:3001, // 你的后端服务地址 changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })这样前端在开发时发起的/api/sign/start请求就会被Vite代理到http://localhost:3001/sign/start完美解决跨域。5.2 启动与测试启动后端服务cd e-sign-server node app.js启动前端开发服务器cd my-e-sign-demo npm run dev打开浏览器访问http://localhost:5173Vite默认端口。你应该能看到我们的合同签署页面。选择一个合同点击“发起签署”。如果一切顺利前端会调用后端接口后端会与e签宝沙箱环境交互最终返回一个签署短链接并加载到iframe中。5.3 常见问题与解决方案在实际集成中你几乎一定会遇到下面这几个问题iframe加载空白或跨域错误 e签宝的签署页面出于安全考虑通常会设置严格的CORS策略。确保你使用的是从/v1/signflows/{flowId}/executeUrl接口获取的官方短链接直接加载到iframe的src中。不要尝试通过Ajax获取页面内容再渲染那样几乎肯定会失败。“无效的Token”或“签名错误” 请仔细检查你的AppId和AppSecret是否正确以及是否放在了正确的请求头中X-Tsign-Open-App-Id和X-Tsign-Open-Token。注意Token是有有效期的我们的后端代码实现了简单的内存缓存生产环境建议使用Redis等做持久化缓存。签署区位置错乱 这通常是在创建模板时签署区坐标定义不准确导致的。在e签宝后台的模板编辑器中仔细拖动签署区组件确保其覆盖在PDF的正确位置。对于动态内容可以考虑使用“关键字定位”模式。回调通知收不到 首先确认你的回调地址是公网可访问的开发时可以用内网穿透工具如ngrok。其次在e签宝沙箱后台配置好回调地址。最后在你的回调接口中务必按照官方文档验证回调签名的有效性防止伪造请求。6. 功能增强与生产环境考量基础功能跑通后我们可以考虑一些增强体验和保障稳定性的措施。6.1 前端体验优化加载状态与错误处理 在iframe加载时显示一个加载动画加载失败时提供重试按钮。可以监听iframe的onload和onerror事件。签署状态轮询 在后端返回flowId后前端可以定期调用/api/sign/status接口查询流程状态。当检测到状态变为COMPLETE时可以自动跳转到成功页面并提示用户“签署完成”。本地存储 将用户最近选择的合同ID存储在localStorage中提升用户体验。6.2 后端安全与健壮性敏感信息管理AppSecret必须使用环境变量或配置中心管理绝不能硬编码在代码中。请求参数校验 对前端传入的contractTemplateId、signerName等进行严格校验和过滤防止SQL注入或非法参数。异步处理与队列 创建签署流程的API调用链较长可以考虑将其放入消息队列如RabbitMQ、Redis Queue异步执行避免HTTP请求超时。前端则通过轮询或WebSocket获取结果。完善的日志记录 记录每一个关键步骤的请求和响应方便问题追踪。特别是回调接口的日志对于排查签署状态同步问题至关重要。数据库设计 你需要设计数据表来存储生成的flowId、对应的用户、合同模板、签署状态、签署完成时间等这是业务逻辑的基础。6.3 部署上线前端项目使用npm run build打包将生成的dist目录部署到Nginx或对象存储如阿里云OSS、腾讯云COS上。后端Node服务建议使用pm2进程管理工具来守护并配置Nginx反向代理。确保服务器的网络能够正常访问e签宝的API域名沙箱和生产环境不同。最后将你在e签宝的账号从沙箱环境切换到生产环境更换对应的AppId、AppSecret和API主机地址并进行全面的功能测试。电子合同涉及法律效力任何环节的疏漏都可能带来风险因此测试务必充分。