Asp.Net Core对接钉钉审批流:从access_token到发起实例全攻略
前阵子公司业务系统要和工作流打通需求很直白业务系统里点了“提交申请”钉钉上就要自动发起一条对应的审批流。我这边负责的服务端是 Asp.Net Core于是研究了一轮钉钉开放平台的服务端API把“发起审批实例”这个流程完整跑通了。这篇文章就用来记录整个对接过程从应用准备、权限配置到拿到 access_token、查询审批模板、再到真正调用接口创建一个审批实例每一步我都会给出可以照抄的代码和调用示例。如果你也是 .NET 开发者准备用 Asp.Net Core 对接钉钉服务端API第一次搞审批流那这篇文章可以帮你少踩很多坑。有些细节钉钉文档里写得比较散我按实际操作顺序重新整理了一遍尽量让你照着做就能通。1. 整体开发思路与方案选型1.1 技术方案官方SDK还是原生HttpClient钉钉开放平台其实提供了官方SDKNuGet 上能搜到AlibabaCloud.SDK.DingTalk相关的包。但实际用下来我建议在 Asp.Net Core 这种后端项目里自己用 HttpClient 封装不要一上来就引整个 SDK。原因有几条。第一官方 SDK 比较重引进来会带一堆依赖而且它内部很多模型和你的业务模型是冲突的。它的数据模型面向的是“通用API调用”你往往要把自己的 DTO 转来转去反而更麻烦。第二SDK 的更新节奏不一定跟得上钉钉接口的迭代有些新接口它可能还没来得及封装。第三审批实例这个接口本身不复杂就是“一个POST 一段JSON”用原生的 HttpClient 加 System.Text.Json 完全够了。所以我的方案是Asp.Net Core Web API 项目 IHttpClientFactory System.Text.Json。IHttpClientFactory 解决 HttpClient 生命周期管理的问题这是官方最佳实践也能方便后续做重试和日志。1.2 整体调用链路钉钉服务端API发起审批实例核心就两个动作获取 access_token这个是所有服务端API调用的统一凭证。拿着 token 调POST /topapi/processinstance/create把审批实例的参数传进去。整个链路是这样业务系统服务Asp.Net Core → DingTalkService自己封装的服务类 → HttpClient注入 IHttpClientFactory → DingTalk Open APIapi.dingtalk.com → 返回审批实例ID钉钉会返回一个process_instance_id这个ID代表创建成功的审批实例。后续你如果要做“查询审批进度”“获取审批结果”都是拿这个ID去换。2. 前置准备应用凭据、权限与AccessToken2.1 创建企业内部应用拿到AppKey和AppSecret这一步要登录钉钉开放平台open.dingtalk.com在“开发者后台”里创建应用。如果是企业内部使用创建“企业内部应用”就行。创建完成后在应用的“凭证与基础信息”页面能看到 AppKey 和 AppSecret。注意现在的文档里通常叫AppKey和AppSecret比较老的教程里可能写的是CorpId和CorpSecret。实际上走服务端API时用AppKey/AppSecret就可以了它对应的就是钉钉内部应用的 corpId corpSecret 那套身份体系。注意AppSecret 是敏感信息绝对不能硬编码到代码里、更不能提交到 Git 仓库。在 Asp.Net Core 里可以用环境变量、appsettings.json 加 User Secrets或者用配置中心的密钥管理。我在本地开发用的是环境变量部署到服务器后走配置中心注入。2.2 配置应用的 API 权限这是新手最容易忽略的一步。创建完应用后默认是“没有权限”的。在开发者后台的“权限管理”里你需要搜索并添加以下权限点审批实例发起对应的权限点名称一般是“审批”相关的权限比如Process.instance.create。获取用户详情、获取部门基础信息用于把业务系统里的人员标识转换成钉钉的 userId。一般对应“通讯录”相关权限。权限点加完之后有一个关键动作必须“发布应用”或“上线应用”。很多人在调试阶段改了权限但它只对开发者自己生效其他人调用就会报“无权限”。企业内部应用一般点“版本管理与发布”里的发布按钮发布后全公司员工就都能用了。2.3 获取 access_token 的两种方式我推荐用新版接口钉钉的 token 获取接口有两套不同版本的文档混在一起非常容易搞混。老版本的接口是 GET 请求GET https://oapi.dingtalk.com/gettoken?appkeyxxxappsecretxxx新版本推荐是 POST 请求路径也变了POST https://api.dingtalk.com/v1.0/oauth2/accessToken Content-Type: application/json { appKey: your_appkey, appSecret: your_appsecret }返回结果{ accessToken: xxxx, expireIn: 7200 }注意新老接口的域名都不同。老接口是oapi.dingtalk.com新接口是api.dingtalk.com。我一开始在好几个接口之间切换的时候就因为这个域名差异吃过亏。统一原则是凭证获取和审批发起全走新体系api.dingtalk.com域名这样最简单省心。2.4 token 为什么要缓存缓存多久access_token 的有效期是 7200 秒但我不建议等到它恰好过期才重新获取。原因很简单获取 token 的接口有频率限制如果多个业务请求同时进来每个都去调一次 token 接口很容易触发限流然后整批请求全部报错。我的做法是用内存缓存把 token 存起来有效期设成 7000 秒留出 200 秒的余量。这样全局只有一个 token过期前提前刷新就不会出现并发去抢 token 的问题。在 Asp.Net Core 里我直接用内存缓存public class DingTalkTokenService { private readonly IHttpClientFactory _httpClientFactory; private readonly IMemoryCache _cache; private readonly IConfiguration _configuration; private static readonly string CacheKey dingtalk:access_token; public DingTalkTokenService(IHttpClientFactory httpClientFactory, IMemoryCache cache, IConfiguration configuration) { _httpClientFactory httpClientFactory; _cache cache; _configuration configuration; } public async Taskstring GetAccessTokenAsync(CancellationToken cancellationToken default) { if (_cache.TryGetValue(CacheKey, out string token) !string.IsNullOrEmpty(token)) { return token; } // 这里加锁防止并发进来同时获取token var lockObject new SemaphoreSlim(1, 1); await lockObject.WaitAsync(cancellationToken); try { // 双重检查等锁的线程拿到锁后再看一次缓存 if (_cache.TryGetValue(CacheKey, out token) !string.IsNullOrEmpty(token)) { return token; } var client _httpClientFactory.CreateClient(dingtalk); var body new { appKey _configuration[DingTalk:AppKey], appSecret _configuration[DingTalk:AppSecret] }; var response await client.PostAsJsonAsync(/v1.0/oauth2/accessToken, body, cancellationToken); response.EnsureSuccessStatusCode(); var json await response.Content.ReadAsStringAsync(cancellationToken); using var doc JsonDocument.Parse(json); token doc.RootElement.GetProperty(accessToken).GetString()!; var expireIn doc.RootElement.GetProperty(expireIn).GetInt32(); _cache.Set(CacheKey, token, TimeSpan.FromSeconds(expireIn - 200)); return token; } finally { lockObject.Release(); } } }这段代码里有几个点要注意SemaphoreSlim是进程内的锁保证同一时刻只有一个线程去调 token 接口。如果将来服务做了水平扩展多实例部署这个方案就不够了得换成 Redis 分布式锁或者用一个后台定时任务专门负责刷新 token。缓存时间用expireIn - 200绝不能直接用 7200。因为从获取到存储这中间有网络耗时加上缓存服务的读取时间紧巴巴地卡在 7199 秒去刷新很容易在极端情况下遇到 token 在边缘时刻过期。PostAsJsonAsync是 System.Net.Http.Json 扩展包里的方法需要在项目里引用Microsoft.Extensions.Http和System.Net.Http.Json这块在 .NET 6 里都是内置的。3. 查询审批模板拿到发起实例必需的 processCode3.1 processCode 是什么发起审批实例需要传一个process_code它相当于审批模板的唯一编号。每个审批模板在钉钉后台都有一个固定的 processCode比如你创建了一个“请假审批”模板它就会有一个对应的 code样子类似PROC-XXXXX。有两种方式拿到它直接在钉钉 OA 管理后台或应用后台的“审批”应用里进入模板详情页查看。通过服务端API查询当前用户可见的审批模板列表。第一种虽然直接但如果模板是同事建的你在后台不一定看得到完整信息。推荐用API的方式查顺便还能确认一下当前应用的权限是否正常。3.2 调用查询模板列表接口查询模板列表的接口是POST /topapi/process/listbyuserid。注意这个接口虽然返回“模板列表”但它需要传一个用户ID表示“以这个用户的视角查他可见的模板”。请求地址POST https://api.dingtalk.com/topapi/process/listbyuserid x-acs-dingtalk-access-token: {access_token} Content-Type: application/json { userid: user123, offset: 0, size: 20 }返回结果里有一个list字段里面每条数据包含process_code、name模板名称等关键字段。可以用这段代码来调用public async TaskListProcessTemplateDto GetProcessTemplateListAsync(string userId, CancellationToken cancellationToken default) { var token await _tokenService.GetAccessTokenAsync(cancellationToken); var client _httpClientFactory.CreateClient(dingtalk); var request new HttpRequestMessage(HttpMethod.Post, /topapi/process/listbyuserid); request.Headers.Add(x-acs-dingtalk-access-token, token); request.Content JsonContent.Create(new { userid userId, offset 0, size 100 }); var response await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); var json await response.Content.ReadAsStringAsync(cancellationToken); var result JsonSerializer.DeserializeDingTalkResultResponseTemplateListResponse(json); if (result null || result.Errcode ! 0) { throw new DingTalkApiException(result?.Errmsg ?? query template failed, result?.Errcode ?? -1); } return result.Result.List; }提示这个接口返回的是一个分页结构size最大建议不要超过 100。如果你公司模板特别多后续要做翻页而不是想着一次全查出来。3.3 和后台“审批”应用的关系有些企业内部应用后台根本看不到 OA 审批的模板配置。这是因为审批应用是钉钉自带的应用和你的企业自建应用是两个不同的应用。你在自建应用里请求审批相关API只要权限点开通了就能操作审批模板不需要你在自建应用后台再单独创建什么。也就是说initiative 是用你 AppKey 对应的身份去动“OA审批”这个应用底下配置的模板。我当时卡了很久一直想着去自建应用后台找“请假审批”模板后来才想明白模板的配置和权限属于钉钉审批应用本身。权限点的打通靠的是开发者后台给应用授权而不是在应用里重新建模板。4. 发起审批实例核心代码实现与参数详解4.1 接口地址与请求头发起审批实例的接口是POST /topapi/processinstance/create。这个接口需要通过请求头传递 access_token方式是x-acs-dingtalk-access-token注意不是老接口里的?access_tokenxxx查询参数。我提供的完整请求结构如下POST https://api.dingtalk.com/topapi/processinstance/create x-acs-dingtalk-access-token: {access_token} Content-Type: application/json { agent_id: 123456789, process_code: PROC-XXXXX, originator_user_id: manager123, dept_id: 100, approvers: user1,user2, form_component_values: [ { name: 请假事由, value: 家中有事 }, { name: 请假天数, value: 1.5 }, { name: 开始时间, value: 2025-03-01 09:00:00 }, { name: 结束时间, value: 2025-03-02 18:00:00 } ] }参数逐个说agent_id自建应用的 AgentId在开发者后台应用详情里能看到。传这个值表示“哪个应用发起的审批”。process_code审批模板唯一标识上一步查出来的。originator_user_id发起人的钉钉 userId不是手机号不是工号是钉钉后台通讯录里的 userId。如果你们系统里存了“员工工号”需要先通过通讯录API把工号换算成 userId。dept_id发起人所在的部门ID必须是当前用户所在的主部门ID。这个很容易漏漏了或传错会报错。approvers审批人 userId 列表多个用逗号拼接。这里要特别注意如果你想按模板里配置的“审批流”走比如先部门主管、再HR不传approvers也可以让钉钉按模板流程自动找申请人但如果你想手动指定审批人就要传。approvers和模板里的审批流程是互斥关系简单说就是外部指定 vs 内部流程二选一。form_component_values表单组件值数组结构每个元素包含name和value。这里的name必须和审批模板里表单控件标题完全一致包括标点符号。4.2 值得注意的审批人参数审批人参数有两种写法approvers是字符串多个 userId 用英文逗号分隔还有一种是approvers_v2它是对象数组可以额外指定审批类型比如或签、会签。如果你只需要简单的“指定给某几个审批人”用approvers就够了。还有一类场景你想让“发起人的直接主管”审批。这时候approvers不传但要确保模板里配置的审批节点是“主管审批”钉钉会自动去找发起人的直接上级。如果模板配置得当服务端什么都不用做。4.3 发起审批实例的服务封装我把发起实例的方法封装成一个服务public class DingTalkApprovalService { private readonly IHttpClientFactory _httpClientFactory; private readonly DingTalkTokenService _tokenService; private readonly IConfiguration _configuration; public DingTalkApprovalService( IHttpClientFactory httpClientFactory, DingTalkTokenService tokenService, IConfiguration configuration) { _httpClientFactory httpClientFactory; _tokenService tokenService; _configuration configuration; } public async Taskstring CreateProcessInstanceAsync( CreateProcessInstanceRequest request, CancellationToken cancellationToken default) { var token await _tokenService.GetAccessTokenAsync(cancellationToken); var client _httpClientFactory.CreateClient(dingtalk); var body new { agent_id request.AgentId 0 ? request.AgentId : long.Parse(_configuration[DingTalk:AgentId]), process_code request.ProcessCode, originator_user_id request.OriginatorUserId, dept_id request.DeptId, approvers string.Join(,, request.ApproverUserIds), form_component_values request.FormComponentValues }; var httpRequest new HttpRequestMessage(HttpMethod.Post, /topapi/processinstance/create); httpRequest.Headers.Add(x-acs-dingtalk-access-token, token); httpRequest.Content JsonContent.Create(body); var response await client.SendAsync(httpRequest, cancellationToken); response.EnsureSuccessStatusCode(); var json await response.Content.ReadAsStringAsync(cancellationToken); var result JsonSerializer.DeserializeDingTalkResultResponseProcessInstanceResult(json); if (result null || result.Errcode ! 0) { throw new DingTalkApiException(result?.Errmsg ?? create process instance failed, result?.Errcode ?? -1); } return result.Result.ProcessInstanceId; } }对应定义的请求模型public class CreateProcessInstanceRequest { public string ProcessCode { get; set; } string.Empty; public string OriginatorUserId { get; set; } string.Empty; public long DeptId { get; set; } public Liststring ApproverUserIds { get; set; } new(); public ListFormComponentValueDto FormComponentValues { get; set; } new(); /// summary /// 默认从配置读取AgentId也可以由调用方指定 /// /summary public long AgentId { get; set; } } public class FormComponentValueDto { public string Name { get; set; } string.Empty; public string Value { get; set; } string.Empty; } public class DingTalkResultResponseT { public int Errcode { get; set; } public string Errmsg { get; set; } string.Empty; public T? Result { get; set; } [JsonPropertyName(request_id)] public string RequestId { get; set; } string.Empty; }4.4 发起成功后返回什么如果调用成功返回结果里的result.process_instance_id就是审批实例ID类似a17b1f08b2f143d3b3f6c79e4c1e0e01。这个ID后面要持久化到业务表里因为查审批进度、接收审批结果回调全部靠它。要注意的是钉钉返回“发起成功”只代表实例创建成功不代表审批已经通过了。最终审批结果是通过“审批事件回调”推给你的服务器而不是靠轮询。所以业务系统对接时要提前把回调接收URL准备好。5. 在 Asp.Net Core 里把它们串成一个可用的审批服务5.1 注册 HttpClient 和内存缓存在Program.cs里把依赖注入配置好builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(dingtalk, client { client.BaseAddress new Uri(https://api.dingtalk.com); client.Timeout TimeSpan.FromSeconds(30); }); builder.Services.AddSingletonDingTalkTokenService(); builder.Services.AddScopedDingTalkApprovalService();这里两个服务的生命周期要特别注意。DingTalkTokenService我用的是Singleton因为内存缓存本身就是进程级的用单例才合理。DingTalkApprovalService我用的是Scoped因为它内部没有状态随请求生命周期走就行了用单例问题也不大但保持默认习惯比较稳妥。5.2 在业务代码里调用假设业务系统有一个“发起请假审批”的接口Controller 里直接调用[HttpPost(leave/apply)] public async TaskIActionResult ApplyLeaveAsync([FromBody] LeaveApplyDto dto, CancellationToken cancellationToken) { // 业务系统先落库记录业务单号 // ... var formValues new ListFormComponentValueDto { new() { Name 请假类型, Value dto.LeaveType }, new() { Name 开始时间, Value dto.StartTime.ToString(yyyy-MM-dd HH:mm:ss) }, new() { Name 结束时间, Value dto.EndTime.ToString(yyyy-MM-dd HH:mm:ss) }, new() { Name 请假天数, Value dto.Days.ToString() }, new() { Name 请假事由, Value dto.Reason } }; // 业务系统的员工工号需要先映射成钉钉userId var dingUserId await _employeeMapping.GetDingUserIdAsync(dto.EmployeeNo, cancellationToken); var request new CreateProcessInstanceRequest { ProcessCode PROC-XXXXXXXX, OriginatorUserId dingUserId, DeptId dto.DeptId, ApproverUserIds new Liststring { manager001 }, FormComponentValues formValues }; var processInstanceId await _approvalService.CreateProcessInstanceAsync(request, cancellationToken); // 把processInstanceId存到业务单据上方便后续状态同步 // ... return Ok(new { ProcessInstanceId processInstanceId }); }这一段就是最核心的使用方式。基本上你业务里所有“要发起钉钉审批”的场景都是把不同单据映射成不同的表单值然后调这个服务。5.3 日志与失败处理发起审批是一个外部调用失败是常态而不是异常。我强烈建议在服务里加日志if (result.Errcode ! 0) { _logger.LogWarning( DingTalk create process instance failed, errcode: {Errcode}, errmsg: {Errmsg}, requestId: {RequestId}, result.Errcode, result.Errmsg, result.RequestId); }日志里务必要带上request_id。钉钉服务端返回错误时会在request_id里给出一个尽量精确的链路标识后续找钉钉技术支持或者查日志都要靠它定位。6. 常见问题与排查技巧实录6.1 错误码速查表与处理建议每次发起失败都要先看返回的errcode。钉钉的错误码体系不算复杂大部分是“权限、参数、token”三类问题。下面是我实践中遇到的几个高频问题和我自己的处理方式错误码常见原因处理建议0成功无需处理88access_token 过期重新获取 token如果用了缓存检查缓存时间是否设置过短40002权限不足应用没开通对应API权限去开发者后台“权限管理”添加权限并发布应用40005参数错误表单字段名不匹配或参数类型错误对照审批模板里的表单控件名称逐字检查name1912重复提交先查一下业务单号是否已发起过审批做好幂等33013无效的部门ID确认dept_id是发起人主部门ID别用子部门或空值34001审批人无效确认approvers里的 userId 存在且在职出现 40002 时不要只盯着代码。我当时查了半天代码最后发现是同事在开发者后台把权限应用只发布到了测试环境生产应用实际没有这个权限点。所以遇到权限类错误先看后台权限、再看发布状态、然后才怀疑代码。6.2 表单字段名不匹配的坑表单组件里的name必须和审批模板里配置的“控件名称”完全一致。这个完全一致包括全角半角、空格、括号。比如模板里写的是“请选择请假类型”你的代码里写“请假类型”DingTalk 不会报参数错误它会认为你填了一个表单里不存在的字段结果是实例创建成功但表单内容为空。这个坑特别隐蔽。我建议在代码里把表单字段名提取成常量类和模板配置做好映射。如果模板是运营人员维护的最好做一个“模板字段字典”用后台配置驱动而不是把字段名硬编码在代码里。6.3 发起人 userId 没做映射钉钉的 userId 和你们业务系统的员工号大概率不是一回事。别指望 dto.EmployeeNo 直接当originator_user_id传。你需要通过钉钉通讯录API用手机号或邮箱查询对应的 userId。查询通讯录用户APIPOST https://api.dingtalk.com/v1.0/contact/users/me x-acs-dingtalk-access-token: {token} Content-Type: application/json { mobile: 13800138000 }这个接口需要通讯录权限返回的userId才是审批人、发起人能用的ID。6.4 dept_id 传错的后果dept_id传错或传别的部门的IDDingTalk 会直接报33013。但如果你传的是用户“非主部门”的部门ID它可能不会直接报错而是审批发起后审批单上的部门显示异常。所以最好的做法是发起的部门ID要么从钉钉查询用户详情时一并拿到主部门ID要么在业务系统里维护主部门ID别让用户自己选。6.5 发起审批失败后要不要重试这个问题要分情况。如果返回的是 40005 参数错误、40002 权限不足这种是配置或代码问题重试一万次也一样失败直接告警去排查就好。如果返回的是网络超时、HTTP 5xx、88 token 过期这种可以考虑重试。但重试前一定要做好幂等因为钉钉服务端可能已经收到了请求并创建了实例只是你这边没收到响应。我的做法是业务表里记录“业务单号 是否已发起审批”发起前先查状态如果状态为“未发起”且本次发起失败才允许重试如果状态为“发起中”先通过查询审批实例接口确认是否真的创建成功。6.6 换新 token 后旧的请求为什么还是失败有一个实际的并发场景值得多说一句。假设有两个服务实例同时在运行服务A刷新了 tokenA服务B还在用 tokenB。当你更新缓存后服务B那边可能还有一批携带 tokenB 的请求在途。这时候钉钉会返回 88 token 已失效但这不一定是 bug而是多实例部署下缓存不一致导致的。如果服务实例数不多最简单的办法是单实例部署 进程内缓存。如果必须多实例就接入 Redis 保存 token并给每个实例一个“获取锁”保证同一时刻只有一个实例去刷新 token刷完写回 Redis其余实例直接读 Redis 里的值。收个尾说点体会对接钉钉审批实例其实本质上就是“拿凭证、查模板、发请求”三步。真正难的不是写代码而是把参数语义搞透比如表单字段名、userId 映射、部门ID这些容易被忽略的细节。我踩得最深的一个坑就是表单字段名不一致实例明明创建成功了但审批单在钉钉里就是没有内容排查了很久才发现是模板控件名称全角半角的问题。所以建议你一定要在测试环境里用一个真实的模板、一个真实的用户把整条链路完整跑一遍眼见为实。另外后续如果你要更进一步可以接着研究审批事件回调钉钉会把“审批通过”“审批拒绝”等事件主动推给你配置的回调URL这样业务系统就能和钉钉审批状态做到实时同步比轮询优雅得多。等有空我再把回调那部分整理出来。