
做企业应用集成的朋友应该都有过类似经历流程走到一半卡在审批环节而钉钉上还得手工再建一张单子两边数据对不上维护成本翻倍。我最近处理的一个需求就是把业务系统里的单据直接通过钉钉服务端API发起审批实例技术栈选用Asp.Net Core整个过程涉及接口选型、Token获取、审批模板配置、表单参数构造等不少细节。这篇就完整记录一下从零到一调通“钉钉服务端API Asp.Net Core 发起审批实例”的过程把关键代码和踩过的坑一并放出来给正在做同类型对接的同学一个参考。1. 整体设计思路与接口选型1.1 为什么选择服务端API而不是其他集成方式钉钉开放平台的接入方式看起来很多有企业内部应用、第三方应用、机器人、H5微应用等等。但如果目标是“业务系统主动发起审批”服务端API基本是唯一合理选择。先看一个典型场景公司内部ERP里有一张销售合同审批需要走钉钉的审批流。这条流程不能由人肉去点必须由ERP服务端发起。到这里可选方案无非几种用钉钉机器人发消息通知人去填审批单——绕了一圈还是手工操作不解决数据联动问题。用H5微应用让用户在钉钉里填写——适合“人发起”的场景不适合“系统发起”。用服务端API直接创建审批实例——系统拿到access_token把表单数据一次性推送给钉钉审批流自动跑起来。这是当前企业自建应用对接审批最标准的姿势。服务端API的优势也体现在职责边界上发起动作和业务逻辑都在服务端便于统一鉴权、统一日志、统一重试钉钉侧只负责审批流运转两边各管一摊出了问题定位也方便。适合参考这篇内容的读者已经有一个Asp.Net Core业务系统想接入钉钉审批能力或者刚接触钉钉开放平台、需要快速把一个审批实例跑通的开发同学。这篇文章不会讲太多钉钉后台的每个按钮而是把最关键的一整条链路讲明白。1.2 审批实例接口的两种版本怎么选钉钉的审批实例创建接口有新旧两套刚开始做的时候容易被文档绕晕。旧版接口长这样POST https://oapi.dingtalk.com/topapi/processinstance/create?access_tokenACCESS_TOKEN新版接口长这样POST https://api.dingtalk.com/v1.0/workflow/processInstances Header: x-acs-dingtalk-access-token: {accessToken}两者核心能力相同都是创建一条审批实例传参基本对齐但风格有明显差异对比维度旧版接口 topapi/processinstance/create新版接口 v1.0/workflow/processInstancesToken位置URL参数请求头Header路径风格偏RPC偏RESTful官方推荐度存量项目仍在用新项目推荐返回结构直接返回result返回id和result参数命名process_codeprocessCode分页等扩展能力一般更友好我的建议很简单如果是全新项目直接用新版接口如果是维护老代码旧版也不是不能跑但没必要在新代码里继续用旧风格。文章后面的代码示例默认走新版接口。选新版还有一个实际好处请求路径统一走api.dingtalk.com域名配合Header传Token整体结构更干净排查问题时一眼能看清请求和响应的对应关系。2. 环境准备与前置配置2.1 用VS Code运行Asp.Net Core项目的准备工作实际开发中很多团队成员并不统一使用Visual StudioVS Code轻量灵活跑Asp.Net Core项目完全够用。这里顺手说一下VS Code环境怎么快速跑起来一个Asp.Net Core Web API项目毕竟调试钉钉接口之前得先有个能跑的服务。第一步确保本机安装了.NET SDK。这里有个常见坑VS Code不会自动帮你装SDK需要在终端里先确认。dotnet --version如果没有安装去Microsoft官网下载对应版本的SDK。建议选LTS版本比如.NET 6/8生产环境稳妥优先。第二步用命令行创建项目dotnet new webapi -n DingTalk.ApprovalDemo cd DingTalk.ApprovalDemo code .VS Code打开项目后如果弹出“是否添加必要的资产”点“是”它会自动生成.vscode文件夹下的launch.json和tasks.jsonF5就能启动调试。第三步启动项目的方式有两种看个人习惯# 方式一dotnet run简单直接 dotnet run # 方式二VS Code调试面板点F5可以断点这里补充一个很实际的调试细节默认的launchSettings.json里applicationUrl通常是localhost加一个随机端口。如果你需要让局域网里的其他机器比如手机装钉钉测试访问你的接口光跑起来还不够要手动改一下applicationUrl: http://0.0.0.0:5199,改成0.0.0.0之后同一局域网内就能通过调试机的IP访问了。别小看这一步我见过不少同学卡在“接口本地通了但钉钉服务器回调不到”的问题上其实就是IP绑定范围不对。2.2 钉钉开放平台侧需要提前配好的几样东西代码写再多钉钉开放平台侧配置不到位接口照样报错。以下几个配置项是发起审批实例的必备前置条件。首先是企业内部应用的AppKey和AppSecret。登录钉钉开放平台后台创建“企业内部应用”创建成功后能看到AppKey和AppSecret。这两个值相当于应用的账号密码后面获取access_token要用的。注意区分“企业自建应用”和“第三方应用”我们这里选企业自建应用即可。其次是接口权限申请。钉钉开放平台对接口权限管控挺严。新建的应用默认很多接口不可调用需要在后台点“权限管理”搜索“审批”相关权限比如“审批实例发起”“审批模板获取”等申请通过后才能正常调通。这里尤其注意权限申请之后不是立刻生效建议等一两分钟再测试。然后是审批模板的processCode。这个参数是发起审批实例时最核心的标识。很多人第一次做会被卡住不知道processCode从哪拿。获取方式一般有几种在钉钉OA审批后台创建一个审批模板例如“合同审批”然后通过服务端接口查询模板列表拿到processCode。打开审批模板的编辑页面URL里通常会带一组编码例如https://xxx.dingtalk.com/xxx?processCodePROC-XXXX这串就是processCode。如果公司有专门的管理员直接找管理员从后台导出也行。建议第一次做的时候先手动创建一个模板把processCode复制出来放到配置里代码层面就少一个变量。后面再通过接口动态获取也不迟。最后是测试人员的UserId。发起审批实例必须指定发起人originatorUserId这个值不是手机号也不是昵称而是钉钉用户的唯一标识。获取方式也有几种管理后台导出通讯录、通过通讯录接口根据手机号查询、或者让测试同事在钉钉里看一下个人资料里的“钉钉号”映射关系。开发阶段最简单的方式是调一次通讯录的用户列表接口把需要的userid打印出来。3. 核心代码实现:从获取Token到发起审批3.1 AccessToken的获取与缓存策略钉钉所有服务端接口的调用前提是先拿到access_token。获取接口很简单GET https://oapi.dingtalk.com/gettoken?appkeyAPP_KEYappsecretAPP_SECRET返回结构一般是这样{ errcode: 0, errmsg: ok, access_token: xxxxx, expires_in: 7200 }access_token有效期是7200秒也就是两个小时。官方接口本身有频率限制如果每次都临时去请求token很容易触发限流还会拖慢业务响应。正确的做法是缓存token过期了再重新获取。基于Asp.Net Core最简单的实现是写一个内存缓存服务或者直接用IMemoryCache。下面我给出一段可以直接落地的代码示例。using Microsoft.Extensions.Caching.Memory; public interface IDingTalkTokenService { Taskstring GetAccessTokenAsync(); } public class DingTalkTokenService : IDingTalkTokenService { private readonly IHttpClientFactory _httpClientFactory; private readonly IMemoryCache _cache; private readonly IConfiguration _configuration; public DingTalkTokenService( IHttpClientFactory httpClientFactory, IMemoryCache cache, IConfiguration configuration) { _httpClientFactory httpClientFactory; _cache cache; _configuration configuration; } public async Taskstring GetAccessTokenAsync() { // 优先从缓存取避免每次请求都调用gettoken if (_cache.TryGetValue(dingtalk_access_token, out string? token) !string.IsNullOrEmpty(token)) { return token; } var appKey _configuration[DingTalk:AppKey]; var appSecret _configuration[DingTalk:AppSecret]; var url $https://oapi.dingtalk.com/gettoken?appkey{appKey}appsecret{appSecret}; var client _httpClientFactory.CreateClient(); var response await client.GetFromJsonAsyncDingTalkTokenResponse(url); if (response null || response.Errcode ! 0 || string.IsNullOrEmpty(response.AccessToken)) { throw new Exception($获取钉钉AccessToken失败errcode{response?.Errcode}, errmsg{response?.Errmsg}); } // 设置缓存这里预留120秒的余量避免在临界点拿到过期token var expiresIn Math.Max(60, response.ExpiresIn - 120); _cache.Set(dingtalk_access_token, response.AccessToken, TimeSpan.FromSeconds(expiresIn)); return response.AccessToken; } } public class DingTalkTokenResponse { public int Errcode { get; set; } public string? Errmsg { get; set; } public string? AccessToken { get; set; } public int ExpiresIn { get; set; } }这里有两个细节值得说明。一是缓存时间为什么要减120秒。access_token过期时间刚好两个小时但网络抖动、服务端时间偏差都可能导致边界情况的token失效。缓存期设置成“有效期减2分钟”等于在客户端侧提前淘汰过期token这是很实用的防御性写法。二是多实例部署时的注意事项。上面的实现基于IMemoryCache如果是单实例部署没问题如果服务是多副本部署每个实例各缓存各的也不会引发功能错误只是会多调几次gettoken问题不大。如果要求更严谨可以换成Redis等分布式缓存思路完全一样把缓存的key和过期时间放Redis即可。3.2 审批实例数据模型设计拿到token之后下一步是构造审批实例的数据。钉钉审批核心无非这几样用哪个模板、谁发起的、哪个部门、审批人是谁、表单里填了什么。先看一下新版接口的请求体大致结构{ processCode: PROC-XXXX, originatorUserId: manager123, deptId: 123456, approvers: [manager456], formComponentValues: [ { name: 请假类型, value: 年假 }, { name: 请假事由, value: 家里有事 } ] }对照这个结构我在Asp.Net Core里设计了对应的请求模型public class CreateProcessInstanceRequest { /// summary /// 审批模板Code /// /summary [JsonPropertyName(processCode)] public string ProcessCode { get; set; } string.Empty; /// summary /// 发起人UserId /// /summary [JsonPropertyName(originatorUserId)] public string OriginatorUserId { get; set; } string.Empty; /// summary /// 发起人部门Id /// /summary [JsonPropertyName(deptId)] public long DeptId { get; set; } /// summary /// 审批人UserId列表 /// /summary [JsonPropertyName(approvers)] public Liststring Approvers { get; set; } new(); /// summary /// 表单控件值列表 /// /summary [JsonPropertyName(formComponentValues)] public ListFormComponentValue FormComponentValues { get; set; } new(); } public class FormComponentValue { /// summary /// 组件名称对应审批模板里控件的name属性 /// /summary [JsonPropertyName(name)] public string Name { get; set; } string.Empty; /// summary /// 组件值 /// /summary [JsonPropertyName(value)] public string Value { get; set; } string.Empty; }这里有一个初学者非常容易犯的错把“组件名称”理解成“控件在页面上显示的文字标签”。比如模板里有一个输入框页面显示“请假天数”但这个控件的name属性可能是“请假天数_xxx”或直接就是“leaveDays”。表单组件传值时用的必须是控件的name属性而不是显示名称。如何确认name最稳妥的方式是调用钉钉的“获取审批模板详情”接口接口返回里会有formComponentList里面每项的name才准。再看一下常用的控件类型。控件类型组件name示例value格式单行输入框TextField字符串多行输入框TextareaField字符串数字NumberField字符串形式的数字如3日期DateFieldyyyy-MM-dd金额MoneyField字符串形式的数字如1500.00单选DDSelectField选中的选项值成员DDSelectField特殊变体用户的userId实际操作中表单值是整套接口里最容易出问题的地方。一个审批模板如果有几十个控件不可能全传只传必填和业务需要的即可。但如果模板里某些控件设置了“必填”漏传会导致接口直接报错。我的做法是先写一个工具方法根据模板详情动态生成表单值字典避免写死。但前期调试阶段为了快速跑通链路也可以先在代码里硬编码几个固定的表单值等验证通过后再完善动态映射。3.3 发起审批实例的调用完整代码设计好模型之后核心调用就比较顺了。这里给出一个完整的审批服务类包含获取token、构造请求、调用接口、解析响应的全流程。public class ApprovalService { private readonly IDingTalkTokenService _tokenService; private readonly IHttpClientFactory _httpClientFactory; public ApprovalService(IDingTalkTokenService tokenService, IHttpClientFactory httpClientFactory) { _tokenService tokenService; _httpClientFactory httpClientFactory; } public async Taskstring CreateProcessInstanceAsync(CreateProcessInstanceRequest request) { var token await _tokenService.GetAccessTokenAsync(); var client _httpClientFactory.CreateClient(); var httpRequest new HttpRequestMessage(HttpMethod.Post, https://api.dingtalk.com/v1.0/workflow/processInstances); httpRequest.Headers.Add(x-acs-dingtalk-access-token, token); var json JsonSerializer.Serialize(request); httpRequest.Content new StringContent(json, Encoding.UTF8, application/json); var httpResponse await client.SendAsync(httpRequest); var body await httpResponse.Content.ReadAsStringAsync(); if (!httpResponse.IsSuccessStatusCode) { throw new Exception($调用钉钉审批接口失败HTTP状态码{httpResponse.StatusCode}响应{body}); } // 这里按新版接口的返回结构反序列化 var result JsonSerializer.DeserializeCreateProcessInstanceResponse(body); return result?.Id ?? throw new Exception(发起审批实例成功但未返回实例Id); } } public class CreateProcessInstanceResponse { [JsonPropertyName(id)] public string? Id { get; set; } [JsonPropertyName(result)] public string? Result { get; set; } }这段代码里有一个值得留意的点无论用新版还是旧版接口都应该格外关注HTTP状态码和业务code两种情况。很多同学只判断HTTP状态码200就以为成功了实际上钉钉的业务异常经常是HTTP 200但业务体里带error信息。所以稳妥的做法是在判断IsSuccessStatusCode的同时还要解析业务返回。新版接口如果出现业务错误响应体里一般有code和message字段建议反序列化成统一的错误对象再处理。为了排查方便我通常还会在调用前后记录日志_logger.LogInformation(开始发起审批实例processCode{ProcessCode}, originator{Originator}, request.ProcessCode, request.OriginatorUserId); // 调用前记录 // 调用结束后记录响应内容 _logger.LogInformation(发起审批实例成功instanceId{InstanceId}, instanceId);日志不要记录完整的表单值因为里面可能包含敏感业务数据记录processCode、发起人等关键标识足够定位问题。3.4 发起后的结果处理与业务联动审批实例创建成功后返回的id就是一串“process instance id”。这个id非常重要它是后续查询审批进度、撤销审批、订阅回调事件的唯一凭证。回到业务场景ERP里一张合同单发起审批后至少要做三件事。第一件事把instanceId保存到业务表里。这是必须的否则后面想查审批状态连对应关系都没有。我习惯在业务表上加一列比如ApprovalInstanceId保存成功之后立刻写回。第二件事更新业务单据的状态。比如把合同状态从“待提交”改成“审批中”避免用户重复提交这个不难但很容易漏。第三件事考虑是否需要订阅审批结果。钉钉审批的最终结果通过/拒绝/撤销是通过回调事件推送的不是发起接口直接返回的。如果业务系统需要审批结束后自动更新ERP状态需要配置“审批事件回调”。这一步属于进阶内容可以在跑通基础发起流程后再做。如果是快跑验证阶段可以直接在发起成功后把instanceId存下来手动去钉钉完成审批然后调“获取审批实例详情”接口确认状态流转。这样省掉回调配置先把主链路跑通。4. 常见问题与排查技巧实录4.1 高频错误码对照表做钉钉开发不可能不碰错误码。这里整理一份我在实际调试中遇到的错误码对照表都是发起审批实例这个场景里最常出现的。错误码含义经典排查方向40078不合法的审批模板检查processCode是否填对、模板是否已停用40079审批模板不存在确认应用是否申请了审批模板权限40080审批实例不存在instanceId有误或已被删除40081审批实例状态不正确状态已经结束的操作会报这个40091参数异常表单必填项缺失、日期格式不对40094审批人不存在或已离职确认approvers里的userId是否有效41003无权限后台接口权限未申请或未生效88服务端限流token获取太频繁检查是否有缓存0成功顺利返回遇到错误码先别急按表格里的方向去排查大概率能命中。如果某个错误码反复出现建议把请求体的JSON原样保存下来和钉钉开放平台文档里的示例逐字段比对很多时候是字段名大小写或者类型不一致导致的。4.2 表单控件Name传错的经典坑这个坑我单独拿出来说因为它是审批实例对接中最容易踩的雷。钉钉的审批模板里每个控件有一个name属性。发起审批实例时formComponentValues里填的name必须是控件name不是页面标签。举个例子页面上显示“请假类型”实际控件的name可能是请假类型_82asdk或者完全是拼音qingjia_leixing。如果直接拿显示标签去传接口会提示参数异常或者直接丢弃这个字段。怎么避坑最稳的做法是调一次“获取审批模板详情”接口。调用之后返回的结构里会有formComponentList里面的每项就是控件的真实定义。写代码时可以先把这个接口的返回打印出来对照着真实name来构造表单值。再补充一个容易忽视的点人员类型控件的value不是用户姓名而是userId。比如“抄送人”“审批人”这类控件如果传了中文姓名接口十有八九报错。必须先把姓名映射成userId再传。如果嫌每次都要查接口麻烦也可以在模板设计阶段就给控件起一个稳定的业务编码名比如“leaveDays”、“contractAmount”然后在代码里约定好映射这样表单构造相对可控不容易被后台改名带偏。4.3 测试环境如何快速定位问题经历过几次联调之后我总结出一套本地调试钉钉接口的流程能显著缩短定位时间。第一步用Postman或curl先把接口调通。这一步在前端界面之外做可以排除代码因素。直接用Postman填上token、processCode、formComponentValues看接口返回什么。如果Postman通了说明问题在代码传参如果Postman也不通问题在配置或参数本身。第二步本地跑Asp.Net Core服务时建议开详细请求日志。我一般在Program.cs里启用控制台日志并且会在HttpClient请求前把序列化后的JSON打出来。注意生产环境不要打印完整的表单值但本地调试阶段可以print出来对照能省很多来回确认的时间。第三步留意钉钉开放平台后台的“应用日志”。里面能看到当前应用调用了哪些接口、返回了什么错误码。这个日志有延迟通常在几分钟后才会显示但有时能帮助定位到通过请求头看不到的信息比如权限是否真正生效。4.4 一个印象深刻的联调事故复盘说一个最近实际遇到的事很有代表性。当时有一个业务接口已经调通线上跑得好好的。后来后台管理员把审批模板改了一下加了一个“必填”的附件控件。结果第二天用户反馈提交单据一直失败。查服务端日志发现报错是“参数异常”但表单值明明都传了。后来到钉钉后台一看新增的附件控件是必填项而附件控件在旧的代码里根本没有对应的value映射等于缺失了一个必填表单值接口自然会拒绝。把新增控件处理逻辑补上之后问题解决。这事的教训就是审批模板的结构不是一成不变的代码层面最好做一个“模板字段和业务字段的动态映射”或者至少在新增必填控件时有一个检查机制避免模板变更导致线上接口默默失败。哪怕没有动态映射能力也要在对接文档里明确约定修改模板后必须通知开发同步更新表单传参。别小看这个约定它在协作开发里能挡掉一大半类似问题。5. 起点之外的扩展思路发起审批实例跑通之后整个钉钉审批链路其实还可以往外延伸不少。比如订阅审批事件回调把审批结果自动回写到业务系统或者用“获取审批实例详情”接口做定时状态同步再比如接入钉钉的通讯录接口动态获取审批人userId而不是在配置里写死。如果业务量上来token管理可以考虑引入分布式缓存审批表单值构造可以独立成一个配置中心模块避免硬编码。不过这些都是后续优化方向核心就是先把“发起审批实例”这条链路吃透后面扩展起来心智负担会小很多。我个人在实际操作中的体会是钉钉服务端API对接难点从来不是调用本身而是前置配置和数据映射。AppKey、权限、processCode、控件name、userId每一个都对不上都会报错而且错误信息往往不直观。只要把这几样前置项理清楚用Asp.Net Core发一个审批实例其实花不了多少时间。希望这篇记录能帮你少踩几个坑。