做企业微信审批流开发这事,说难不算难,说简单也真不简单。我前前后后给三家企业搭过自定义审批模板,从刚开始连回调签名验证都调不通,到后面把多级审批、条件分支、消息回写全流程跑稳,中间踩过的坑差不多能写一本小册子。这篇就把我从零搭建企业微信自定义审批流的完整过程、完整代码、还有那些最容易翻车的细节整理出来,给准备自己搞审批流的同学做个参考。
这个内容适合谁看?两类人。一类是公司内部IT或开发,需要把线下的报销、请假、采购、合同流程搬到企业微信上,又嫌官方审批后台不够灵活;另一类是做企业服务集成的外包或SaaS开发,需要把审批能力嵌入到自己的业务系统里。看完你就能自己搭一套带自定义模板、自动回调、状态回写的审批流,改改模板ID和字段ID就能适配不同场景。
1. 为什么我选择自建审批模板,而不是直接套用系统审批
很多团队一开始都会纠结:企业微信自带的审批功能就能用,为什么还要自己开发一套?这个问题的答案,基本决定了整个项目的技术选型方向。
1.1 原生审批的边界在哪里
企业微信原生审批系统确实好用,打开就能发起请假、报销、补卡,审批人也直接在聊天里点一下就行。但一旦业务复杂起来,原生审批就开始露怯了。举几个我实际遇到的例子:
第一个例子是字段联动。我们有个采购流程,金额超过五万就要自动追加一个财务总监审批节点,金额低于五万只走部门经理就行。原生审批也能配条件分支,但条件规则是基于表单字段的固定逻辑,业务规则的调整往往要等官方的功能更新,不能通过接口动态控制。
第二个例子是审批数据要回写业务系统。原生审批结束后,数据留在企业微信后台,想同步到公司的ERP、OA或者财务软件,得靠人工导出或者第三方工具去捞数据,再手动对账。我们当时要对接内部预算系统,审批结果要实时扣减预算额度,这个原生审批完全做不到。
第三个例子是流程不可编程。比如审批通过之后自动创建工单、自动发邮件、自动触发下一条流程;审批驳回之后自动退回并通知发起人修改。这些“审批之外”的动作,原生审批都不支持。你需要一个能跟业务系统对话的审批引擎。
1.2 自建审批模板的核心优势
自建审批流本质上是把企业微信当作“审批的展示层和触达层”,真正的流程逻辑、数据存储、业务联动全部放在自己的服务里。这样做的好处非常直接:
- 表单字段完全自定义。文本框、数字框、日期、单选、多选、明细表、图片附件,想怎么组合就怎么组合,想什么时候加字段就什么时候加字段。
- 审批节点动态计算。审批人可以是固定的人,也可以根据表单内容动态算出,比如根据金额、部门、城市等条件匹配对应的审批人。
- 审批结果实时回写。审批状态变化通过回调瞬间通知到自己的系统,业务数据可以无缝联动。
- 审批流程可版本化。模板可以改版、灰度、回滚,这在企业制度频繁变动的环境下非常有用。
1.3 系统整体架构设计
我建议的整体架构是:企业微信管理后台创建一个自建应用,应用里配置审批模板;业务后端服务负责三件事,第一是维护模板和流程配置的数据表,第二是调用企业微信API发起审批,第三是接收企业微信的审批事件回调并更新状态;前端则提供一个H5页面嵌入到企业微信工作台,用来发起审批和查看我的申请。
选择后端技术栈时,我用的是Node.js + Express,因为公司现有团队偏JavaScript方向。你完全可以用Python Flask、Java Spring Boot、Go Gin替换,核心逻辑是一样的,只是企业微信接口调用方式略有差异。
这里有个重要的设计原则:不要把所有逻辑都塞在回调处理函数里。因为企业微信回调可能乱序、重复、甚至延迟,我后来把回调消息先写入队列,再异步处理,保证审批状态最终一致。这个后面会详细说。
2. 前置准备:企业微信应用、可信域名与API权限
开始写代码之前,必须先在企业微信管理后台把应用的“地基”打牢。很多审批流开发做到一半突然卡壳,回头一看全是配置问题。
2.1 创建自建应用与获取基础凭证
登录企业微信管理后台,进入“应用管理” -> “自建应用” -> “创建应用”,填上应用名称和 Logo,创建完成后你会拿到两个关键信息:AgentId 和 Secret。另外还要在企业信息里找到 CorpId,这三个值基本上贯穿整个开发过程。
创建应用后,默认只有基础权限,调用审批相关接口需要额外申请接口权限。在“应用管理”里找到你的自建应用,进入“API权限”,把以下权限加进去:
- 审批:获取审批模板详情、提交审批申请、获取审批申请详情、获取审批数据
- 通讯录:读取成员、读取部门(用于解析审批人)
- 消息推送:发送应用消息(用于通知审批人)
这里要提醒一下:Secret和AgentId不要在代码里写死,建议放在环境变量或配置中心。因为Secret一旦泄漏,别人就能拿它调用你的企业接口,风险非常大。我见过有人把Secret直接提交到Git仓库,结果整个企业通讯录都被拉走,教训很深。
2.2 可信域名与回调URL配置:一字之差就是千古恨
这是审批流开发中踩坑率最高的环节。很多热词搜索里也常看到“可信域名”相关的问题,比如“该域名主体为第三方服务商,请使用企业主体域名”,这个提示的意思就是:你配置的域名没有通过企业微信的可信域名校验。
企业微信要求,所有需要调用JS-SDK的页面域名,以及接收回调的URL域名,都必须先完成域名归属验证。具体的配置路径是:“应用管理” -> 你的自建应用 -> “网页应用及JS-SDK” -> “可信域名”。
配置可信域名有几个硬性要求:
- 域名必须是企业主体名下的,且已经完成ICP备案。第三方服务商的域名不能用。
- 域名必须支持HTTPS访问,证书有效。
- 必须下载企业微信提供的校验文件,放到域名根目录下,确认能通过外网访问。
回调URL的配置在“接收消息”里,需要填一个完整的URL,比如https://yourdomain.com/wecom/callback,同时设置Token和EncodingAESKey。这里的Token和EncodingAESKey是回调加解密用的,跟业务Token完全是两回事,后面代码里会用到。
需要特别说明的是,回调URL和可信域名是两套体系,即使回调URL配了,JS-SDK的页面访问还是需要可信域名;即使可信域名通过,也不代表回调就能通。我见过不少同学在这两个地方来回改,结果一个是校验文件没放对,另一个是回调URL路径写错。
2.3 权限范围与通讯录同步
自建应用默认只能看到部分通讯录信息。如果审批流里需要按照部门找审批人,或者要读取成员UserID,就必须在“权限管理”里把通讯录权限设为“企业通讯录”或者至少“自建应用可见范围”包含对应的部门和成员。
这一步的坑在于:应用可见范围直接决定了“谁能看到这个应用”,也决定了接口能拉到哪些人。如果你把可见范围设成某个部门,但审批流程里却要选另一个部门的经理,接口会返回“userid不存在”或者权限不足。我当时就在这卡了半天,最后才发现是可见范围没包含审批人所在的部门。
3. 数据模型与流程引擎设计:先把表结构想清楚
代码是表象,数据模型才是审批流的灵魂。如果表结构设计得不好,后面加需求改流程会非常痛苦。
3.1 审批模板与字段表设计
我设计了四张核心表:模板表、字段表、节点表、审批记录表。
模板表(approval_templates)用来存模板基础信息:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| template_name | varchar | 模板名称 |
| wecom_template_id | varchar | 企业微信审批模板ID |
| creator | varchar | 创建人UserID |
| status | tinyint | 启用/停用 |
| created_at | datetime | 创建时间 |
字段表(approval_template_fields)用来存模板的表单字段定义:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| template_id | int | 关联模板表 |
| field_key | varchar | 字段标识,如amount |
| field_name | varchar | 字段显示名,如报销金额 |
| field_type | varchar | Text/Textarea/Number/Date/Select/Money |
| required | tinyint | 是否必填 |
| options | text | 单选/多选的选项,JSON格式 |
| sort | int | 排序 |
节点表(approval_nodes)用来定义审批流程:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| template_id | int | 关联模板表 |
| node_name | varchar | 节点名称,如部门经理审批 |
| node_type | tinyint | 1顺序审批 2会签 3或签 |
| approver_type | varchar | user(指定人)/role(角色)/dept(部门负责人) |
| approver_value | varchar | 审批人的UserID或部门ID |
| condition_expr | text | 可选,节点生效条件,如amount > 50000 |
| sort | int | 节点顺序 |
审批记录表(approval_records)用来存审批实例状态:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| sp_no | varchar | 企业微信审批编号 |
| template_id | int | 模板ID |
| creator | varchar | 发起人UserID |
| current_status | tinyint | 审批中/通过/驳回/撤销 |
| apply_data | text | 提交的表单数据,JSON格式 |
| callback_data | text | 回调原始数据,JSON格式 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
3.2 审批节点类型与执行规则
审批节点这块,我用一个例子来说明。假设报销流程是这样的:金额小于5000元,只需要直属上级审批;金额在5000元到50000元之间,需要部门经理审批;金额超过50000元,需要部门经理和财务总监都审批。
对应到表结构,我会有三个节点:
- 节点1:条件
amount <= 5000,审批人为直属上级 - 节点2:条件
amount > 5000 && amount <= 50000,审批人为部门经理 - 节点3:条件
amount > 50000,节点类型为会签,审批人为部门经理和财务总监
注意,企业微信创建审批模板的时候,本身就支持配置审批人,但如果你完全依赖企业微信的审批模板配置去做条件分支,那你仍然没法把“审批结果回写业务系统”“自动触发后续动作”这些逻辑接进来。所以我自己的做法是:企业微信那边只配一个“通用审批模板”,所有自定义逻辑都放在自己的节点表里,通过代码决定传给企业微信的审批人究竟是谁。
这样做有个好处:企业微信那边只当它是普通的审批流,而真正的流程规则掌握在自己手里。后续改流程只需要改数据库,不用去企业微信后台改模板,也不用重新发布应用。
3.3 审批状态机设计
审批状态我统一用如下状态:
- 0:审批中
- 1:审批通过
- 2:审批驳回
- 3:审批撤销
审批中的记录可能经历节点推进,比如从“部门经理审批中”变成“财务审批中”,这个中间态可以单独用current_node字段记录,不必给每个节点都设计一个状态位。状态流转只发生在三个地方:提交时设为审批中,回调里收到审批结果为通过/驳回时更新,主动撤销时设为撤销。
这里有个细节:企业微信回调里,审批通过和审批驳回对应的事件是不同的,审批驳回时可以附上审批意见,在向发起人发送通知时,要把审批意见一起带过去,否则发起人只知道被拒了,不知道原因。
4. 后端接口实现:从Token管理到回调加解密
下面进入核心代码实现。为了让你能直接跑通,我把代码拆成几个模块:工具函数、提交审批接口、回调处理接口。我用Node.js + Express实现,依赖包只用了express、axios和crypto(Node内置)。
4.1 环境变量与依赖
创建一个项目目录,初始化npm并安装依赖:
mkdir approval-demo cd approval-demo npm init -y npm install express axios在项目根目录创建.env文件,填入你的企业微信配置:
WECOM_CORP_ID=ww1234567890 WECOM_AGENT_ID=1000002 WECOM_SECRET=your_secret_here WECOM_CALLBACK_TOKEN=your_token WECOM_CALLBACK_ENCODING_AES_KEY=your_encoding_aes_key这里说一下,EncodingAESKey在企业微信后台生成,是一个43位的字符串,Base64解码后是32字节,用来做AES加解密。Token是任意字符串,用于签名验证。两个值都要妥善保管。
4.2 AccessToken管理:必须缓存,不能每次现取
企业微信接口调用都需要AccessToken,获取接口在https://qyapi.weixin.qq.com/cgi-bin/gettoken,参数是corpid和corpsecret。AccessToken有效期默认7200秒,但企业微信官方要求必须缓存,不建议频繁请求。
我封装了一个带缓存的获取函数:
// utils/wecom.js const axios = require('axios'); const CORP_ID = process.env.WECOM_CORP_ID; const SECRET = process.env.WECOM_SECRET; let accessTokenCache = { token: '', expiresAt: 0 }; async function getAccessToken() { // 如果token还没过期,直接用缓存 if (accessTokenCache.token && Date.now() < accessTokenCache.expiresAt - 300000) { return accessTokenCache.token; } const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken`; const resp = await axios.get(url, { params: { corpid: CORP_ID, corpsecret: SECRET } }); if (resp.data.errcode !== 0) { throw new Error(`获取access_token失败: ${JSON.stringify(resp.data)}`); } accessTokenCache.token = resp.data.access_token; // 有效期是7200秒,提前300秒过期保证安全 accessTokenCache.expiresAt = Date.now() + resp.data.expires_in * 1000; return accessTokenCache.token; } module.exports = { getAccessToken };这里减300秒是为了避免token恰好在使用时过期。在多实例部署时,建议用Redis缓存这个token,因为它天然是全局共享的。
4.3 提交审批申请接口
企业微信创建审批申请的接口是POST /cgi-bin/oa/applyevent,请求体中模板ID、发起人、审批人、表单内容都要传。
我写了一个提交审批的路由,前端传过来一个模板标识和表单数据,后端根据模板配置解析出企业微信模板ID、审批人列表,再拼装请求体。
// routes/approval.js const express = require('express'); const axios = require('axios'); const { getAccessToken } = require('../utils/wecom'); const { getTemplateConfig, buildApplyData } = require('../services/approvalService'); const router = express.Router(); router.post('/submit', async (req, res) => { try { const { templateCode, creator, formData } = req.body; // 1. 从数据库读取模板配置(省略SQL,用函数代替) const template = await getTemplateConfig(templateCode); // 2. 根据表单数据和节点条件,计算实际审批人 const approvers = template.nodes .filter(node => evalNodeCondition(node.condition_expr, formData)) // 实际生产请用规则引擎,避免eval .map(node => node.approver_value); // 3. 去重并拼成数组 const approverList = [...new Set(approvers)]; // 4. 构造apply_data const applyData = buildApplyData(template.fields, formData); // 5. 调用企业微信接口 const accessToken = await getAccessToken(); const resp = await axios.post( `https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=${accessToken}`, { creator_userid: creator, template_id: template.wecom_template_id, use_template_approver: 0, approver: approverList, apply_data: { contents: applyData }, summary_list: [ { summary_info: [ { text: template.template_name, lang: 'zh_CN' } ] } ] } ); if (resp.data.errcode !== 0) { return res.status(500).json({ success: false, error: resp.data }); } // 6. 把审批编号存库,这个编号用于后续回调关联 const spNo = resp.data.sp_no; await saveApprovalRecord({ spNo, templateCode, creator, formData, status: 0 }); res.json({ success: true, spNo }); } catch (error) { console.error('提交审批失败', error); res.status(500).json({ success: false, error: error.message }); } }); module.exports = router;buildApplyData这一层是重点。企业微信的表单控件类型很多,每种控件对应的数据结构都不一样。我封装了一个映射函数:
// services/approvalService.js function buildApplyData(fields, formData) { return fields.map(field => { const value = formData[field.field_key]; switch (field.field_type) { case 'Text': return { control: 'Text', id: field.wecom_control_id, // 需要从企业微信模板详情里拿到控件ID value: { text: value || '' } }; case 'Textarea': return { control: 'Textarea', id: field.wecom_control_id, value: { text: value || '' } }; case 'Number': return { control: 'Number', id: field.wecom_control_id, value: { new_number: value || 0 } }; case 'Money': return { control: 'Money', id: field.wecom_control_id, value: { new_money: value || 0 } }; case 'Date': return { control: 'Date', id: field.wecom_control_id, value: { new_date: value } }; case 'Select': return { control: 'Select', id: field.wecom_control_id, value: { select: { options: [{ key: value }] } } }; default: return null; } }).filter(Boolean); }注意这里的wecom_control_id,不是你自己定义的字段标识,而是企业微信审批模板里每个控件的唯一ID。获取方式有两个:一是调用“获取审批模板详情”接口,传template_id,接口会返回模板的所有控件及其ID和属性名;二是直接在企业微信后台编辑审批模板时,浏览器控制台可以看到每个控件的id。更推荐第一种,自动化处理才不会出错。
4.4 回调接收与验证:最难啃的骨头
回调接收是企业微信审批流开发中最容易出问题的地方。企业微信服务器收到审批状态变更后,会向你的回调URL发POST请求。请求参数包括msg_signature、timestamp、nonce和echostr(首次验证时才有echostr),body是加密的XML。
先处理关键的URL验证阶段。当你第一次在后台配置回调URL时,企业微信会向你的URL发一个GET请求,带timestamp、nonce、echostr三个参数,你需要用msg_signature验证后,解密echostr,返回明文内容给企业微信,配置才能生效。
代码如下:
// routes/callback.js const express = require('express'); const crypto = require('crypto'); const router = express.Router(); const TOKEN = process.env.WECOM_CALLBACK_TOKEN; const AES_KEY = process.env.WECOM_CALLBACK_ENCODING_AES_KEY; // 把EncodingAESKey解码为Buffer,取前16字节作为AES IV const encodingAESKey = Buffer.from(AES_KEY + '=', 'base64'); const key = encodingAESKey; const iv = key.slice(0, 16); // 签名验证 function verifySignature(msgSignature, timestamp, nonce, echostr) { const arr = [TOKEN, timestamp, nonce, echostr].sort(); const str = arr.join(''); const sha1 = crypto.createHash('sha1').update(str).digest('hex'); return sha1 === msgSignature; } // AES解密 function decrypt(encrypted) { const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); decipher.setAutoPadding(false); let decrypted = Buffer.concat([ decipher.update(encrypted, 'base64'), decipher.final() ]); // PKCS7去掉填充 const padSize = decrypted[decrypted.length - 1]; decrypted = decrypted.slice(0, decrypted.length - padSize); return decrypted.toString('utf8'); } // 处理URL验证 router.get('/callback', (req, res) => { const { msg_signature, timestamp, nonce, echostr } = req.query; if (!verifySignature(msg_signature, timestamp, nonce, echostr)) { return res.status(401).send('signature error'); } // 密文格式为:随机16字节 + 4字节网络序长度 + 明文 + receiveid const decrypted = decrypt(echostr); // 提取真实明文:从第20位开始,前4位是长度 const msgLen = decrypted.readUInt32BE(4); const msg = decrypted.slice(8, 8 + msgLen).toString('utf8'); res.send(msg); });这里有个极易踩的坑:EncodingAESKey是43位的Base64字符串,需要补一个等号变成长度44才能正确Base64解码。另外AES解密后要去掉填充,而且明文格式前面16字节随机数、4字节长度、后面才是真正的回复内容。我第一次写的时候忘了去随机值和长度,直接拿整个解密结果当明文,结果返回给企业微信的东西多了前缀,后台一直提示验证失败。
解决这个问题的另一个思路是直接使用官方提供的加解密库。企业微信官方提供了各语言版本的加解密库,比如node版本的@wecom/crypto,用它就不用自己处理这些底层细节了。不过自己实现一遍,对理解原理很有帮助,出了问题也能排查。
4.5 审批事件回调处理
配置好URL验证后,企业微信会把审批状态变更事件以POST请求推给你的回调地址。POST请求的body是加密的XML,解密后XML里会有Event节点。审批相关的Event包括:
- approval_info:审批状态变更通知,里面有SpNo(审批编号)、ApprovalStatus(1审批通过、2审批驳回、3撤销)、ApprovalNode(当前节点)等。
处理流程是:先解密XML,再根据Event类型走不同分支。
router.post('/callback', express.text({ type: 'text/xml' }), async (req, res) => { const { msg_signature, timestamp, nonce } = req.query; const encryptedMsg = req.body; // 验证签名 const arr = [TOKEN, timestamp, nonce, encryptedMsg].sort(); const sha1 = crypto.createHash('sha1').update(arr.join('')).digest('hex'); if (sha1 !== msg_signature) { return res.status(401).send('signature error'); } try { // 解密 const decryptedXml = decrypt(encryptedMsg); // 解析XML里的ToUserName, AgentID, Event, SpNo等字段 // 这里用简易正则提取关键字段,生产环境建议用xml2js const event = /<Event><!\[CDATA\[(\w+)\]\]><\/Event>/.exec(decryptedXml); const spNoMatch = /<SpNo><!\[CDATA\[(\w+)\]\]><\/SpNo>/.exec(decryptedXml); const statusMatch = /<ApprovalStatus>(\d+)<\/ApprovalStatus>/.exec(decryptedXml); if (event && event[1] === 'approval_info' && spNoMatch) { const spNo = spNoMatch[1]; const approvalStatus = statusMatch ? parseInt(statusMatch[1]) : 0; // 更新数据库状态 await updateApprovalStatus(spNo, approvalStatus); // 发送通知给发起人 await notifyApplicant(spNo, approvalStatus); } // 企业微信要求5秒内返回,否则会重试 res.send('success'); } catch (error) { console.error('回调处理失败', error); res.status(500).send('error'); } });需要注意,企业微信回调有个超时机制:你的服务器必须在5秒内响应success,否则企业微信会认为发送失败并重试。未来如果你要在这个回调里做很多耗时操作(比如写库、发通知、调第三方API),最好先把消息放进队列,立即返回success,再由worker异步处理。我后来就是这么改的,用RabbitMQ或者Redis队列都行。
解密后的XML里还包含审批人节点信息。在企业微信的事件回调中,approval_info事件的数据结构大致是:
<xml> <ToUserName>corp_id</ToUserName> <AgentID>1000002</AgentID> <Event>approval_info</Event> <ApprovalInfo> <SpNo>202101010001</SpNo> <SpStatus>1</SpStatus> <ApprovalNode> <NodeStatus>1</NodeStatus> <NodeAttr>1</NodeAttr> <NodeType>1</NodeType> <Items> <Item> <ItemStatus>1</ItemStatus> <ItemName>张三</ItemName> <ItemUserid>ZhangSan</ItemUserid> <ItemTime>1609459200</ItemTime> </Item> </Items> </ApprovalNode> </ApprovalInfo> </xml>这里需要特别区分SpStatus和ApprovalStatus。笔试的时候很容易混淆:SpStatus是审批实例的整体状态(1审批中、2已通过、3已驳回、4已撤销),ApprovalNode里的NodeStatus是节点状态。我之前写的解析逻辑用错了字段,导致审批已经通过了,系统里还显示审批中,排查了半天才发现是字段取错。
4.6 消息通知:审批通过/驳回后触达发起人
审批状态变化后,要及时通过各种方式通知发起人。最直接的方式是调用企业微信应用消息接口,发送文本卡片消息。
接口是POST /cgi-bin/message/send,参数包含touser、msgtype、agentid、textcard等。下面是一个发送文本卡片的例子:
async function notifyApplicant(spNo, status) { const { creator } = await getApprovalRecord(spNo); const accessToken = await getAccessToken(); const statusText = status === 1 ? '已通过' : (status === 2 ? '已驳回' : '已撤销'); const title = `审批结果通知`; const description = `您的审批单 ${spNo} 已${statusText}`; await axios.post( `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${accessToken}`, { touser: creator, msgtype: 'textcard', agentid: process.env.WECOM_AGENT_ID, textcard: { title, description, url: `https://yourdomain.com/approval/detail?spNo=${spNo}`, btntxt: '查看详情' } } ); }这里的url必须使用可信域名下的地址,否则在企业微信里点不开。我之前把详情页地址指向内网IP,结果审批人收到通知后点击没有任何反应,查了半天发现是因为域名不可信。
5. 前端模板配置与审批发起页面
后端接口就绪后,还需要一个让普通员工能发起审批的前端页面。这个页面我做成H5,嵌入到企业微信工作台。
5.1 模板配置器:给管理员用的配置界面
管理员需要一个配置页,用来创建模板、添加字段、配置节点。我建议做两个页面:
- 模板列表页:看到所有已创建的模板,支持新增、编辑、停用。
- 模板编辑页:左侧是表单字段配置区,右侧是流程节点配置区。
表单字段配置区的核心交互是:管理员点“新增字段”,选择字段类型(文本、数字、金额、日期、单选、多选),填写字段名称和字段标识,必要时填写选项值。保存后生成field_key对应的控件定义。
流程节点配置区稍微复杂一点,要支持:
- 添加审批节点:选择节点类型(顺序审批、会签、或签)
- 设置审批人:可以选指定成员、指定部门负责人、指定角色
- 设置条件:基于某个字段的表达式(例如amount大于50000才走当前节点)
前端把配置的数据以JSON格式提交到后端接口,后端写入approval_templates和approval_template_fields、approval_nodes三张表。
5.2 审批发起页面实现
员工在企业微信工作台打开应用后,会看到一个模板列表,点进具体模板会动态渲染表单。这里用的是简单的JSON渲染方案,前端根据template.fields数组生成对应的输入组件。
核心的提交逻辑是用ajax调用上面的/approval/submit接口,把表单数据post过去。下面是一个简化版HTML页面:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>发起审批</title> </head> <body> <h1>报销申请</h1> <form id="approvalForm"> <label>金额</label><input type="number" id="amount" name="amount"> <label>事由</label><textarea id="reason" name="reason"></textarea> <button type="submit">提交审批</button> </form> <script> document.getElementById('approvalForm').addEventListener('submit', async (event) => { event.preventDefault(); const formData = { amount: document.getElementById('amount').value, reason: document.getElementById('reason').value }; const resp = await fetch('/approval/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ templateCode: 'expense', creator: 'WangXiaoMing', formData }) }); const data = await resp.json(); if (data.success) { alert('提交成功,审批编号:' + data.spNo); } else { alert('提交失败:' + data.error); } }); </script> </body> </html>这个页面的creator我在demo里写死成WangXiaoMing,实际部署时应该从企业微信的登录态获取。怎么拿登录态?企业微信提供了OAuth2授权,前端跳转到授权链接,后端拿code换取用户身份,拿到UserID后再发起审批。
5.3 移动端适配的注意事项
企业微信内置浏览器本质上是基于Chromium内核的,对H5的支持还算不错。但有几个细节需要注意:
- 页面宽度尽量适配375px左右的屏幕,按钮和输入框不能太小。
- 企业微信有自己的底部导航和右上角菜单,页面的固定元素要避开这些区域。
- 如果需要调用企业微信JS-SDK的能力(比如选人、选部门、打开摄像头),需要引入
https://res.wx.qq.com/open/js/jweixin-1.2.0.js并完成agentConfig注入。
JS-SDK的签名逻辑是:后端用corpId、agentId、timestamp、nonceStr、url这些参数计算出签名,前端调用wx.agentConfig注册。签名算法跟普通微信JSSDK类似,但需要额外的agentConfig加签。这块内容比较细,有需要的同学可以单独写一篇,审批流里如果只做表单填写,不一定要用JSSDK。
6. 常见坑位与排查实录
按照惯例,把我在实际开发中踩过的一些坑整理一下,这部分最花时间,但也是最有价值的。
6.1 可信域名与回调URL的连环坑
很多同学配置可信域名时,遇到“该域名主体为第三方服务商,请使用企业主体域名”的提示,下意识以为是企业微信版本问题,实际上是因为域名ICP备案主体跟当前企业主体不一致。解决方式就是:让域名备案到当前企业名下,或者使用企业已有的备案域名子域名。
回调URL验证失败,最常见的几个原因:
- 签名验证代码写错。注意排序是数组sort(),不是字符串直接比较。
- 解密后的明文没有去掉随机前缀和长度头。
- 返回了success以外的内容,包括返回JSON、返回空字符串。
- 服务器时区不对,导致timestamp校验失败(其实企业微信没强制校验timestamp,但最好保持一致)。
6.2 审批人参数解析失败
创建审批申请时,approver数组传的是UserID,不是姓名也不是手机号。如果你在后台配置的可见范围不含审批人,接口会返回60011之类的错误码。此外,如果审批人是部门负责人,需要先把部门负责人对应的UserID解析出来再传给接口,不能直接传部门ID。
解析部门负责人的接口是GET /cgi-bin/user/list?department_id=xxx&fetch_child=0,通过department_id拉取成员列表,再判断谁是负责人。这个方法在企业微信通讯录只有一个负责人时很有效,如果多个负责人会返回一个数组,你需要决定取第一个还是全部。
6.3 回调事件漏收、重复与加解密出错
回调漏收的原因通常是没有在5秒内返回success,或者服务器有防火墙拦截了企业微信的IP。处理方式是:把企业微信服务器的IP段加到白名单(虽然官方没强制要求,但能减少很多莫名奇妙的连接超时)。
重复回调也是常态。企业微信对未收到成功响应的回调会重试多次,所以你的回调处理逻辑必须天然幂等。比如更新审批状态前,先查一次当前状态,如果已经是最终状态就跳过。如果不做幂等,同一个审批单被回调了三次,数据库里status字段会来回覆盖,还可能出现和业务数据不一致的情况。
加解密出错最常见的原因就是EncodingAESKey和Token配置不一致。后台配置的EncodingAESKey是43位Base64字符串,程序里解码时要加等号;签名里用的Token要跟后台完全一致,不能多空格。
6.4 状态同步与消息通知异常
审批已经通过,但业务系统没反应,或者发起人没收到通知。大概率是回调解析里用错了状态字段。我在前面已经强调过,SpStatus和ApprovalStatus、NodeStatus是三个不同的概念,我用一张表帮你理清楚:
| 字段 | 含义 | 取值 |
|---|---|---|
| SpStatus | 整个审批单状态 | 1审批中、2已通过、3已驳回、4已撤销 |
| ApprovalStatus | 回调事件中的审批单状态 | 1审批通过、2审批驳回、3撤销 |
| NodeStatus | 当前节点状态 | 1审批中、2已通过、3已驳回 |
这里要提醒的是,企业微信回调事件里并没有直接叫ApprovalStatus的字段,实际XML里是SpStatus。我在前面的示例代码里统一用了ApprovalStatus作变量名,对应到XML字段就是SpStatus。在集成时务必检查你解析的是不是SpStatus,别和张三李四的文档搞混。
6.5 环境差异与多应用隔离
测试环境和生产环境不要共用一个企业微信应用,否则会出现测试数据推给真实员工、审批人收到测试通知等事故。我见过最惨的一次是同事在测试环境点了个按钮,结果给全公司几百人发了审批通知,还是没法撤销的那种。
建议做法是:申请两个应用,一个叫“XX审批流(测试)”,一个叫“XX审批流(生产)”,可见范围分别设为测试部门和生产全体员工。在环境变量里区分WECOM_AGENT_ID和WECOM_SECRET,配置中心里也分命名空间。数据库表也都分开,测试环境的回调URL指向测试服务器,生产环境的回调URL指向生产服务器。
7. 安全加固与性能优化建议
审批流涉及公司内部敏感数据,安全和性能必须重视。
7.1 安全基线
第一,所有回调接口必须验证签名,签名算法就是官方文档那一套,不要自己发明。第二,对所有出口请求校验企业微信返回的errcode,非0就要告警。第三,AccessToken、Secret、EncodingAESKey等敏感配置绝不能出现在日志里。我曾经在调试时把整个请求对象打印出来,结果Token全打到日志里,后来改配置才换掉。
还需要做好数据权限控制。审批记录表里的apply_data包含员工填写的敏感字段(比如身份证号、银行卡号、医疗信息),后端查询接口必须校验当前用户是不是审批单的发起人或者审批人,否则任何人都能通过接口拉取全部审批数据。
7.2 Token缓存、限流与幂等
AccessToken缓存我在前面已经给过代码。在企业微信审批流这种场景,token是全局公用的,多实例部署时建议用Redis,避免每个实例各自拉取token导致限流。企业微信接口有频率限制,一般调用量不会打满,但万一你有批量同步任务,最好自己加一个简单的令牌桶限流。
审批回调处理要幂等。做法是:在approval_records表给sp_no加唯一索引,并在更新状态前判断当前状态。如果已经是从终态(通过/驳回/撤销)回调过来的,直接return。还有一种情况是审批通过后又收到驳回回调(理论上不可能,但防一手),按“以最新回调为准”处理。
7.3 日志、监控与告警
我把审批流的运行日志分成了三类:
- 请求日志:记录每次审批提交、回调接收的关键参数,不记录表单详细内容,保护隐私。
- 错误日志:记录所有调用企业微信接口失败的返回体、堆栈信息。
- 审计日志:记录谁在什么时间发起了什么审批,审批状态如何变化,这个日志只增不改,用于纠纷追溯。
监控方面,重点监控三个指标:审批提交接口的耗时和失败率、回调接收的成功率、审批状态更新的延迟。一旦回调超过5分钟还没有更新某条审批单的状态,就触发告警。我还会定期跑一个定时任务,把处于“审批中”状态且超过N天没有变化的记录捞出来,人工确认是否卡在某个节点上。
最后分享一个经验
做了几套审批流下来,我最大的体会是:审批流开发的核心难点不在企业微信接口本身,而在于流程数据的建模和边界情况的处理。接口文档写得很清楚,照着调就行;但“审批通过之后业务系统要做什么”“回调重复发了怎么办”“审批人换了部门怎么处理”这些业务问题,才是真正需要花时间设计的。
如果你也是第一次接触企业微信审批流,我的建议是从一个最简单的单节点审批开始跑通,比如“部门经理审批+文本字段”,先跑通提交、回调、通知闭环,再去加会签、条件分支、自定义字段这些进阶功能。直接一步到位做复杂流程,出了错你都不知道是模板配错了还是接口调错了。
最后再分享一个小技巧:企业微信的“审批”应用里其实支持导入外部审批数据,但接口文档和历史版本信息都藏得比较深,建议你在动手前把官方文档里“OA审批”那一节完整读一遍,尤其是“提交审批申请”和“获取审批模板详情”两个接口的参数说明。我就是因为一开始没仔细看apply_data里每个控件的value结构,导致提交后审批人看到的内容全是空的,排查了很久。