用kintone与外部电子签名服务来管理合同

cnDevNet发表于:2020年12月08日 14:46:32更新于:2020年12月23日 09:35:30

Index

概要

电子签名技术现在越来越普及,很多公司已应用电子合同。主要是因为纸张既不易保存,也不易查找,已不再作为管理合同的唯一方式。
kintone作为非常易于扩展的paas平台,自然也是可以非常简单的接入任何电子签名系统。
今天我们就以电子牵平台作为范例给大家介绍如何接入外部电子签名系统。

关于电子牵

电子牵是国内知名互联网企业字节跳动子公司巨量引擎下推出的电子签名服务。累计已有20w+中小企业和个人使用。

同时他们提供了完整的api,通过它,我们就能在kintone上完成一整套电子合同签名流程。

电子牵api文档:https://bytedance.feishu.cn/docs/doccndBPF4kvuUjQZ0H8vRLenyg

功能简要

以往用户常常使用kintone来做电子合同的管理,但是这些合同依然需要再手动打印成纸质合同,然后和对方进行签署并保存。
现在通过和电子牵的对接,这些电子合同将直接附带上电子签名,然后生成法律效应,完全可以抛弃纸质合同,直接用kintone来管理它们。

流程说明

  • 签署双方需要完成在电子牵上的认证及绑定信息

  • 通过kintone的合同管理来发起包括合同上传,撤销,发起签署,归档等操作

  • 将这些有效数据同步到电子牵平台

  • 发起短信及邮件通知到签署双方

  • 签署双方在电子牵的页面完成签署操作

  • 签署成功后,电子牵平台会自动发起回调到kintone平台,更新合同的所有签署信息

对于合同管理者来说:所有操作都只需在kintone完成,无需打开电子牵页面。

而对于需要签署的外部用户或者企业来说:所有的认证及签署都能在电子牵端完成。无需拥有或登录kintone平台。这样既保证了kintone系统的安全,又让用户无担忧的完成签署过程 。

电子牵时序图:

0015fd85a4d547c9b4ecee6d0a248d5


下面我们就来结合kintone和电子牵来开发一个demo程序(本示例程序使用nodejs作为服务端语言开发)

请提前准备

  • 电子牵token:appCode,appSecret等用来调用电子牵的api

  • 邮件服务器:用来发送邮件通知

  • 短信接口:用来发送短信通知 

开发

因为这里涉及到了很多接口,包括文件上传等,同时上传到电子牵都需要添加签名,如果在客户端处理,会暴露签名密钥等安全问题,同时还有很多复杂的逻辑处理及回调处理。所以,认证和合同管理这两个应用我们都不是直接在kintone上调用电子牵的服务器,而是再架设了一个服务端中间件来处理这些操作。

服务端中间件的功能:

  • 提供kintone请求的接口:处理来自kintone的请求,并转发到电子牵平台。

  • 提供电子牵的回调接口:处理来自电子牵平台的回调请求(认证成功,签署成功等)。

  • 向用户发起签署的短信及邮件通知。

认证

通过电子牵的时序图我们知道,我们首先要对签署双方进行验证,并且绑定。

这边做了一个简单的kintone端到电子牵端的认证应用,来完成这电子牵平台的认证。

0015fd85cfb9e6016308a24121ee3c8

我们通过中间服务器,将kintone发起的认证请求转发到电子牵的“申请个人认证链接”接口,成功后就会返回适用于该用户的认证地址,同时会将这个认证链接通过短信及邮件发送给用户。

接下来只要用户完成认证后,就能实现和电子牵的绑定。中间服务器的认证回调接口会去kintone更新这条记录的认证状态。

注意:

电子牵的用户签署页面暂时只支持手机验证,因为需要用到人脸识别,上传身份信息等,建议在邮件中加入二维码链接,来方便用户使用。

服务端的代码片段示例:

const kintoneApi = require("../../libs/kintoneApi");
const { config } = require("../../config");
const letsignApi = require("../../libs/letsignApi");
const { v1: uuidv1 } = require('uuid');
const sendMail = require('../../libs/sendMail');
const YJSMS = require('../../libs/YJSMS');

exports.getCertUrl = async (recordId) => {
    const userApp = config.kintone.appInfo.userApp;
    const tokens = [userApp.appToken];
    const kintoneApiObj = new kintoneApi(tokens);
    const letsignApiObj = new letsignApi();

    const param = {
        "app": userApp.appId,
        "id": recordId
    };
    const userFieldCode = userApp.fieldCode;
    const { record } = await kintoneApiObj.getRecord(param);
    let refCode = record[userFieldCode.refCode].value ? record[userFieldCode.refCode].value : uuidv1();
    const refAsyncNotifyUrl = config.letsign.personCertNotifyUrl;
    let letsignparam = [record[userFieldCode.userId].value, record[userFieldCode.mobile].value, refCode, refAsyncNotifyUrl];

    const getCertUrlStatus = await letsignApiObj.getCertUrl(...letsignparam);
    if (getCertUrlStatus.code === 200) {
        let certUrl = getCertUrlStatus.data.url;
        const updataParam = {
            "app": userApp.appId,
            "id": recordId,
            "record": {
                [userFieldCode.url]: {
                    "value": certUrl
                },
                [userFieldCode.refCode]: {
                    "value": refCode
                }
            }
        }

        return kintoneApiObj.updateRecord(updataParam).then(resp => {
            //send email
            let tos = record[userFieldCode.email].value;
            let subject = "电子牵和kintone的认证";
            let user = record[userFieldCode.name].value;
            let emailInfo = [user, tos, subject, certUrl, "person"];
            const mail = new sendMail();
            mail.sendCertMail(...emailInfo);

            //send sms
            let smsInfo = [record[userFieldCode.mobile].value, record[userFieldCode.name].value, certUrl, true, 'person'];
            let yjSMS = new YJSMS();
            yjSMS.sendCertUrlSMS(...smsInfo);

            return {
                'code': 200,
                'data': resp
            }
        }).catch(err => {
            return {
                'code': 500,
                'data': err.message
            }
        })
    }
    return getCertUrlStatus;
}

合同管理

0015fd85da215cf96dbcf6f332a06f4

kintone端的自定义开发

功能:

  • 用户在kintone上发起合同上传,合同撤销,发起签署,合同归档等请求到中间服务器

  • 通过当前状态字段,禁用非当前状态能执行的功能,防止误操作

0015fd85e292251934995dca2ce0129

服务端中间件的开发

实现上传,撤销,签署,归档等所有电子牵接口

上传:
  • 上传分为文件上传模版上传

  • 文件上传是直接上传待签名的完整合同,而模版上传是将模版合同和合同数据一起传送给电子牵。

  • 这边使用的是文件上传,大家可以根据自己的实际需求来判断使用哪种方式来进行合同的上传。

签署:
  • 签署文件需要定义签名坐标或者关键字,这样用户拿到的签署页面会在默认的坐标或者关键字位置添加签名。
    在自动签署中是必须定义好的。而手动签署的话,用户可以根据参数选择是否调整签名位置。

  • 如果是企业自身,可以实现自动签署功能,来简化签署步骤。

  • 签署接口发起后,向签署双方发起短信邮件通知。

撤销:
  • 如果双方都没有进行签署操作,此时可以发起撤销操作,来中断此次合同的签署。

  • 撤销后kintone上的合同对应电子牵平台的transcode将重置。

  • 撤销后,可以重新上传合同文件,并且更新新的transcode。

归档:
  • 只有所有签署方都完成签署后,才能发起归档操作。

  • 归档后的合同,合同将生效,同时无法继续添加签署方,无法对合同做任何修改。

签署功能的代码片段:

const kintoneApi = require("../../libs/kintoneApi");
const { config } = require("../../config");
const letsignApi = require("../../libs/letsignApi");
const { v4: uuidv4 } = require('uuid');
const utils = require("../../libs/utils");

exports.applyForSign = async (recordId) => {
    const contractApp = config.kintone.appInfo.contractApp;
    const userApp = config.kintone.appInfo.userApp;
    const companyApp = config.kintone.appInfo.companyApp;
    const tokens = [contractApp.appToken, userApp.appToken, companyApp.appToken];
    const kintoneApiObj = new kintoneApi(tokens);
    const letsignApiObj = new letsignApi();

    const param = {
        "app": contractApp.appId,
        "id": recordId
    };
    const contractFieldCode = contractApp.fieldCode;
    const { record } = await kintoneApiObj.getRecord(param);
    const contractCode = record[contractFieldCode.contractCode].value;
    let contractRelatedTable = record[contractFieldCode.contractRelatedTable].value;
    const initiatorIdSignKey = record[contractFieldCode.initiatorIdSignKey].value;
    const initiatorId = record[contractFieldCode.InitiatorId].value;
    const InitiatorCompanyCode = record[contractFieldCode.InitiatorCompanyCode].value;
    const InitiatorSearch = record[contractFieldCode.InitiatorSearch].value;
    //循环合同关系者 发起手动签署  如果用户手动添加了合同发起人到合同关系人中,那就以手动的方式来让发起人签署
    let needInitiator = true;
    let errorInfo = {};
    let contractStatus = contractApp.contractStatus.startSign;
    for (let user of contractRelatedTable) {
        let relatedInfo = user.value;

        let companyOpenCode = relatedInfo[contractFieldCode.companyOpenCode].value;
        let personOpenCode = '';
        if (companyOpenCode) {
            personOpenCode = relatedInfo[contractFieldCode.entPersonOpenCode].value;
        }
        else {
            personOpenCode = relatedInfo[contractFieldCode.personOpenCode].value;
        }
        let signKey = relatedInfo[contractFieldCode.signKey].value;

        //判断是否将发起人加入了进来。
        if (personOpenCode === initiatorId && companyOpenCode === InitiatorCompanyCode) needInitiator = false;
        //判断是否已签
        if (relatedInfo[contractFieldCode.status].value === contractApp.signStatus.signed) {
            contractStatus = contractApp.contractStatus.signing;
            continue;
        };
        //判断已经发送过签署请求(transactionCode)
        let transactionCode = relatedInfo[contractFieldCode.transactionCode].value ? relatedInfo[contractFieldCode.transactionCode].value : `${recordId}-${uuidv4()}`;
        let options = [contractCode, transactionCode, personOpenCode, signKey, companyOpenCode];
        let signResult = await letsignApiObj.applyForSign(...options);
        if (signResult.code !== 200) {
            errorInfo = signResult;
            continue;
            // return signResult;
        }
        relatedInfo[contractFieldCode.signUrl].value = utils.formatTokintoneLink(signResult.data.signUrl, '签署链接');
        relatedInfo[contractFieldCode.transactionCode].value = transactionCode;
    }

    //发起自动签署 将合同发起者自动添加到合同表 因为怕循环里同时生成的时间戳会一样,所以用v4随机数
    if (needInitiator) {
        contractStatus = contractApp.contractStatus.signing;
        const InitiatorTransactionCode = `${recordId}-${uuidv4()}`;
        const companySignatureCode = config.letsign.companySignatureCode;
        const autoSignResult = await letsignApiObj.autoSign(contractCode, InitiatorTransactionCode, initiatorId, initiatorIdSignKey, InitiatorCompanyCode, companySignatureCode);
        if (autoSignResult.code !== 200) {
            errorInfo = autoSignResult;
            // return autoSignResult;
        }
       
        const InitiatorInfo = {
            value: {
                [contractFieldCode.companySearch]: {
                    value: InitiatorSearch
                },
                [contractFieldCode.signKey]: {
                    value: initiatorIdSignKey
                },
                [contractFieldCode.signUrl]: {
                    value: contractApp.signStatus.autoSigned
                },
                [contractFieldCode.transactionCode]: {
                    value: InitiatorTransactionCode
                },
                [contractFieldCode.status]: {
                    value: contractApp.signStatus.notSigned
                }
            }
        }
        contractRelatedTable.push(InitiatorInfo);
    }

    //更新合同关系表
    const updataParam = {
        "app": contractApp.appId,
        "id": recordId,
        "record": {
            [contractFieldCode.contractRelatedTable]: {
                "value": contractRelatedTable
            },
            [contractFieldCode.contractStatus]: {
                "value": contractStatus
            }
        }
    }

    return kintoneApiObj.updateRecord(updataParam).then(resp => {
        var arr = Object.keys(errorInfo);
        if (arr.length != 0) {
            return errorInfo;
        } else {
            return {
                'code': 200,
                'data': resp
            }
        }
    }).catch(err => {
        return {
            'code': 500,
            'data': err.message
        }
    })
}

因为篇幅的关系,这边不对代码做太多的演示,具体我们将代码开源,有兴趣的可以在github上查看:

https://github.com/pokemon3636/letsign

应用方面的演示可以联系我们的产品部门,可以提供完整的演示环境。

注意事项

直接使用此处提供的程序范例的情况,才望子不予以保证程序的正常运行。

才望子不提供对程序范例的技术支持。

    注意:贴代码时请注意格式并使用"代码语言",与本文无关的问题请至“讨论社区”提问。
    您需要登录后才可以回复