云计算百科
云计算领域专业知识百科平台

【YiFeiWebApi】企业微信审批直通易飞ERP:从回调验签到自动审核的完整实战(.NET Minimal API)

企业微信审批直通易飞ERP:从回调验签到自动审核的完整实战(.NET Minimal API)

场景:领导在企业微信手机上审批通过一张销售订单,几秒后,易飞 ERP 里这张单据的"审核"自动完成——全程没有人再登录 ERP 点一次按钮。

本文记录我用 .NET(Minimal API)从零实现这条链路的完整过程:企微回调验签、AES 消息解密、事件路由、审批表单解析、易飞 WebAPI 调用,以及 8 个真实踩坑记录。全部代码可落地,配置驱动,新增审批类型零代码。

一、需求背景

公司的进销存跑在鼎捷易飞 ERP 上,单据审核一直是"两头跑"的模式:

  • 业务员在 ERP 里开单;
  • 打印/截图找领导签字,或者领导登录 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 秒内返回
    });

    两个关键约束:

  • 必须在 5 秒内响应,否则企微判定推送失败并重试——这直接决定了下游易飞调用的超时预算(见踩坑 7);
  • 业务失败返回 500 而不是吞掉异常返回 200——把重试机制交给企微,比自己做队列简单可靠得多。
  • 四、事件解析与规则路由

    审批状态变化事件的明文 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-企微易飞同步服务


    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【YiFeiWebApi】企业微信审批直通易飞ERP:从回调验签到自动审核的完整实战(.NET Minimal API)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!