企业微信审批直通易飞ERP:从回调验签到自动审核的完整实战(.NET Minimal API)
场景:领导在企业微信手机上审批通过一张销售订单,几秒后,易飞 ERP 里这张单据的"审核"自动完成——全程没有人再登录 ERP 点一次按钮。
本文记录我用 .NET(Minimal API)从零实现这条链路的完整过程:企微回调验签、AES 消息解密、事件路由、审批表单解析、易飞 WebAPI 调用,以及 8 个真实踩坑记录。全部代码可落地,配置驱动,新增审批类型零代码。
一、需求背景
公司的进销存跑在鼎捷易飞 ERP 上,单据审核一直是"两头跑"的模式:
领导经常在外,审批滞后;审批完忘记在 ERP 里点"审核",下游流程卡住,又要电话催。
而公司内部协作已经全面跑在企业微信上,审批功能天然支持手机端。于是目标明确:
企微审批通过 → ERP 单据自动审核,审批即生效。
易飞本身提供了 WebAPI(/api/copi06/approve 这类审核接口),企微提供了审批事件回调,中间缺的就是一个"翻译官"——这就是本文的同步服务。
二、整体架构
┌──────────┐ ①提交审批 ┌──────────────┐
│ 业务员企微 │ ────────────→ │ 企微审批中心 │
└──────────┘ └──────┬───────┘
②审批通过,推送事件
(POST,密文+签名)
▼
┌─────────────────────┐
│ 同步服务(本文主角) │
│ 验签 → 解密 → 路由 │
│ 拉表单 → 组装报文 │
└──────────┬──────────┘
③datakeys 审核请求
▼
┌─────────────────────┐
│ 易飞 ERP WebAPI │
│ /api/copi06/approve │
└─────────────────────┘
同步服务的职责边界很清晰:
- 收:企微回调(URL 验证 GET + 事件推送 POST),加解密;
- 翻译:根据"审批模板 ID + 审批状态"查规则表,决定调易飞哪个接口、怎么组参;
- 发:调用易飞 WebAPI,解析 std_data 响应;
- 兜底:失败让企微重试,成功后去重。
整个服务就是一个 ASP.NET Core Minimal API 单项目,几百行核心代码。

三、企微回调:验签与解密
3.1 两种回调
企微自建应用的"接收消息"配置里有两种请求:
- GET:后台保存回调 URL 时的一次性验证,带 msg_signature/timestamp/nonce/echostr 四个参数,要求解密 echostr 后原样返回明文;
- POST:事件推送,签名三参数在 URL Query 上,密文在请求体的 <Encrypt> 节点里。
这一点很多人第一次会踩坑——以为密文也在 Query 里,或者以为 POST 是明文 JSON。企微自建应用回调只有"安全模式"(加密模式),不存在明文模式。
3.2 验签算法
签名 = 把 token, timestamp, nonce, encrypt 四个字符串字典序排序后拼接,取 SHA1:
public static string ComputeSignature(string token, string timestamp, string nonce, string encrypt)
{
var items = new[] { token, timestamp, nonce, encrypt };
Array.Sort(items, StringComparer.Ordinal); // 注意是 Ordinal
using var sha1 = SHA1.Create();
var hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(string.Concat(items)));
var sb = new StringBuilder(40);
foreach (var b in hash) sb.Append(b.ToString("x2"));
return sb.ToString();
}
3.3 解密算法
- Key = Base64Decode(EncodingAESKey + "="),43 位补一个 = 凑成 44 位 Base64,解出 32 字节 AES-256 密钥;
- IV = Key 前 16 字节;
- AES-256-CBC,手动去 PKCS#7 填充(填充块按 32 字节算,不是 16);
- 解密后的明文结构:16字节随机串 + 4字节大端消息长度 + 消息体 + ReceiveId(CorpId),最后要校验 ReceiveId 是否等于自己的 CorpId。
private string AesDecrypt(string encryptedBase64)
{
var cipherBytes = Convert.FromBase64String(encryptedBase64);
using var aes = Aes.Create();
aes.Key = _aesKey;
aes.IV = _aesKey.Take(16).ToArray();
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.None; // 手动去填充
using var decryptor = aes.CreateDecryptor();
var plain = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
var pad = plain[^1];
if (pad is < 1 or > 32) throw new CryptographicException("解密填充非法");
var content = plain[..^pad];
var msgLen = (content[16] << 24) | (content[17] << 16) | (content[18] << 8) | content[19];
var message = Encoding.UTF8.GetString(content, 20, msgLen);
var receiveId = Encoding.UTF8.GetString(content, 20 + msgLen, content.Length – 20 – msgLen);
if (receiveId != _corpId) throw new CryptographicException("消息 ReceiveId 不匹配");
return message;
}
官方 C# 版 WXBizMsgCrypt 也可以直接用,自己实现一遍的好处是心里有数——排查签名不匹配时知道每一环在哪。
3.4 回调端点(Minimal API)
// GET:URL 验证
app.MapGet(callbackPath, (HttpContext ctx, WXBizMsgCrypt crypt) =>
{
var q = ctx.Request.Query;
var echo = crypt.VerifyUrl(q["msg_signature"], q["timestamp"], q["nonce"], q["echostr"]);
return Results.Text(echo);
});
// POST:事件推送
app.MapPost(callbackPath, async (HttpContext ctx, WXBizMsgCrypt crypt,
IApprovalSyncService sync, ILogger<Program> logger,
CancellationToken ct) =>
{
var q = ctx.Request.Query;
string plainXml;
using (var reader = new StreamReader(ctx.Request.Body))
{
var body = await reader.ReadToEndAsync(ct);
try { plainXml = crypt.DecryptPostXml(body, q["msg_signature"], q["timestamp"], q["nonce"]); }
catch (Exception ex)
{
logger.LogWarning(ex, "回调消息验签/解密失败");
return Results.Text("invalid signature", statusCode: 401);
}
}
try { await sync.HandleEventAsync(plainXml, ct); }
catch (Exception ex)
{
// 业务失败返回 500,企微会重试推送(最多 3 次)
logger.LogError(ex, "处理企业微信事件失败,等待重试");
return Results.Text("retry", statusCode: 500);
}
return Results.Text("success"); // 必须在 5 秒内返回
});
两个关键约束:
四、事件解析与规则路由
审批状态变化事件的明文 XML 长这样(节选):
<xml>
<ToUserName><![CDATA[wwxxxxxxxx]]></ToUserName>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[sys_approval_change]]></Event>
<ApprovalInfo>
<SpNo>202609180001</SpNo>
<TemplateId><![CDATA[C4en9STxxxxxxxxxxxxx]]></TemplateId>
<SpStatus>2</SpStatus>
</ApprovalInfo>
</xml>
三个关键字段:SpNo(审批单号)、TemplateId(审批模板)、SpStatus(状态:2=通过,3=驳回,4=撤销,6=通过后撤销,7=删除)。
路由的核心设计:TemplateId + SpStatus → 易飞接口,全部放在配置里:
"YiFei": {
"BaseUrl": "http://erp.example.com:8080",
"License": "<X-API-License>",
"CompanyId": "001",
"SecurityCode": "******",
"TimeoutSeconds": 3,
"Rules": [
{
"TemplateId": "C4en9STxxxxxxxxxxxxx",
"SpStatus": 2,
"PayloadType": "DataKey",
"ApiPath": "/api/copi06/approve",
"DocTypeField": "单别",
"DocNoField": "单号"
}
]
}
PayloadType 支持两种入参形态:
| DataKey | approve / disapprove / invalid / delete | 从审批表单里按控件标题取"单别"“单号” |
| Document | create / update | JSON 模板 + 表单值渲染完整单据体 |
这样接入新的审批类型 = 企微建一个模板 + 配置文件加一条规则,不改代码。
五、拉取审批详情,取单别单号
事件推送里只有 SpNo,没有表单内容。需要再调企微 oa/getapprovaldetail 接口(用自建应用的 CorpSecret 换 access_token 后调用)拉回完整表单。
表单是控件数组,每个控件有 title 和 value,不同控件类型(Text/Number/Date/Selector/明细表格)的 value 结构完全不同,解析时按类型分流:
- Text/Number/Money → 直接取值;
- Date → timestamp 转 yyyyMMdd;
- Selector → 取选中项 key;
- 明细表格 → 按行展开,供 Document 模板渲染。
然后按规则里配置的控件标题(DocTypeField: "单别"、DocNoField: "单号")取出两个值,取不到直接抛异常,绝不发脏数据给 ERP:
private async Task<(bool Ok, string Message)> InvokeByDataKeyAsync(
TemplateRule rule, ApprovalForm form, string spNo, CancellationToken ct)
{
var docType = ReadRequiredField(form.Scalars, rule.DocTypeField, spNo, "单别");
var docNo = ReadRequiredField(form.Scalars, rule.DocNoField, spNo, "单号");
return await _yiFei.InvokeByDataKeyAsync(rule.ApiPath, docType, docNo, ct);
}
六、调用易飞 WebAPI
易飞 WebAPI 的契约是标准 std_data 包裹,审核类接口入参为 datakeys:
{
"std_data": {
"parameter": {
"datakeys": [
{ "doc_type_no": "2213", "doc_no": "2609015" }
]
}
}
}
请求头三个认证字段:X-API-License、X-API-CompanyId、X-API-SecurityCode。
响应:
{
"std_data": {
"execution": { "code": 0, "description": "审核成功" },
"parameter": {
"result": {
"success": [{ "doc_type_no": "2213", "doc_no": "2609015" }],
"error": []
}
}
}
}
注意 HTTP 200 不代表业务成功,必须判断 execution.code == 0,否则会漏掉"单据状态不允许审核"这类业务错误。
成功后以 SpNo + ApiPath 为键做 24 小时内存去重——企微的重试机制意味着同一事件可能收到多次,而"同一审批单挂多个动作"的场景也要求去重键带上接口路径。
七、踩坑记录(本文精华)
这部分是拿真实调试时间换来的,按排查顺序列。
坑 1:服务只监听 localhost,公网推不进来
Kestrel 默认绑定 localhost:5000。本机测试一切正常,企微推送石沉大海,控制台连一行日志都没有。启动日志里一行小字暴露了问题:
Now listening on: http://localhost:5000 ← 只有回环地址
修复,一行:
builder.WebHost.UseUrls("http://0.0.0.0:5000");
坑 2:Windows 防火墙没有 5000 入站规则
改成 0.0.0.0 后依然收不到。netsh advfirewall firewall show rule 里查不到 5000,公网请求全被拦。加规则:
netsh advfirewall firewall add rule name="WeComSync 5000" dir=in action=allow protocol=TCP localport=5000
判别方法:从外部 curl http://服务器IP:5000/health,通不了就是网络层问题,别先怀疑代码。
坑 3:签名不匹配 → Token/AESKey 必须逐字符一致
报 CryptographicException: 事件消息签名不匹配。程序里加一行启动日志,把加载到的 Token/AESKey 打出来和企微后台逐字符比对(改配置后必须重启进程才生效)。这个错误基本只有两个来源:值不一致、或改了配置没重启。
坑 4:TemplateId 抄错两个字母,规则永远不匹配
最隐蔽的一个。把 TemplateId 从后台 URL 抄进配置时看错了字符(Ctn 看成 Cnt),于是事件到了、解密成功了,但规则匹配不上,日志一直打印"忽略未配置规则的审批事件"。
排查技巧:在"忽略"日志里同时打印企微推送值和所有已配置值,一次对比:
忽略未配置规则的审批事件:TemplateId=C4en…Ctnpy9PE(推送值)
已配置的 TemplateId=[C4en…Cntpy9PE]
^^ 就差这里
结论:以企微实际推送的值为准,不要相信手工转录。教训是这类 ID 永远用文本复制,不要经过截图。
坑 5:企微审批 API 权限与可信 IP
两个后台开关漏一个,getapprovaldetail 就调不通:
- 审批 → API → 可调用应用:必须勾选自建应用,否则报 60011 no permission;
- 自建应用 → 可信 IP:必须加服务器公网 IP,否则报 ip not allowed。
坑 6:易飞 License 绑定机器码
易飞 WebAPI 的 License 按机器码(Machine key)授权,换一台服务器部署,License 立刻 401。响应里会给出当前机器的 Machine key,拿去给易飞重新授权即可。本地测试和正式部署的机器码不同,都要授权。
坑 7:易飞超时 8 秒 > 企微 5 秒上限
最初易飞超时设 8 秒。设想这个时序:易飞 6 秒处理完 → 企微 5 秒已断开判定失败 → 企微重试 → 同一张单被审核两次。虽然易飞对已审核单会返回"状态为 Y,不可审核"兜底,但正确做法是把易飞超时压到 3 秒以内,宁可让企微重试,也不让调用悬在半空。
坑 8:单据字段不能"看着像数字就当数字"
渲染 create 类单据体时,单别 2213、日期 20260918 如果被序列化成 JSON 数字,易飞字符串字段直接出错。模板占位符统一输出字符串,数量/单价等真正需要数字的字段用显式过滤器声明(如 {{订单数量|number}})。
八、可靠性设计小结
| 企微推送失败 | 返回 500,企微自动重试 3 次 |
| 处理超时 | 易飞超时 3s < 企微 5s 硬上限 |
| 重复推送 | SpNo + ApiPath 内存去重 24h |
| 脏数据 | 表单取不到单别/单号直接失败,不发 ERP |
| 安全 | 回调 SHA1 验签 + AES 解密 + ReceiveId 校验 |
| 可观测 | 规则加载日志、命中日志、单据映射日志、失败详情 |
九、总结
这条链路技术上没有单点难点,难在把两套系统的契约精确对齐:企微的加解密、事件结构、5 秒时限,加上易飞的 std_data、机器码授权、字符串字段。坑几乎全是"差一个字符""差一条规则"级别的。
架构上最值得复用的是配置驱动的规则表:TemplateId + SpStatus → ApiPath + 入参映射。审批/驳回/作废/删除这类 datakeys 动作,新增一个审批类型只需要在企微后台建模板、在配置里加一条规则,程序零改动。
如果你也在做企微与 ERP 的集成,欢迎评论区交流。
步骤一:企业微信新增ERP客户订单(可从ERP同步)

步骤二:企业微信审批流程
步骤三:审批结束后自动触发,易飞-API接口,执行成功。

前置条件:(1)YiFeiWebApi–易飞API接口 (2) WeComYiFeiSync-企微易飞同步服务
网硕互联帮助中心






评论前必须登录!
注册