资讯详情

PHP对接钉钉审批流闭环:发起、回调与状态同步实战

📅 2026/10/13 16:57:25 | 华诺云谱 👁 阅读
PHP对接钉钉审批流闭环:发起、回调与状态同步实战
简介本资源是一套基于PHP与钉钉开放平台深度集成的完整审批流实现方案面向中高级PHP开发者及企业级应用对接工程师解决PHP后端主动发起审批、钉钉侧完成审核、并同步回写/更新业务数据的核心闭环问题。压缩包为21KB的ZIP文件共含12个PHP脚本涵盖数据库连接get_db_conn.php、钉钉加解密DingtalkCrypt.php、pkcs7Encoder.php、sha1.php、错误码管理errorCode.php、部门与用户数据获取get_dept.php、UserLogin.php、审批单创建PrCreate.php、申请发送send_apply.php及基础数据封装basic_data.php、get_data.php等关键模块结构清晰、职责分明便于快速嵌入现有系统。目前已有193人学习下载提供开箱即用的可运行代码骨架包含完整签名验签逻辑、敏感信息加密处理、API调用封装及典型业务场景适配示例显著降低钉钉审批系统对接门槛与调试成本。1. 钉钉审批流闭环PHP系统主动发起、钉钉端人工审核、结果回写PHP数据库——这不是API调用教学而是生产环境里跑通的完整链路你有没有遇到过这种场景公司内部用 PHP 写的工单/人事/资产系统每次员工提交申请管理员却得切到钉钉App里手动点“通过”或“拒绝”再回到PHP后台手动更新状态两边数据不同步、状态滞后、审计无痕、半夜被call起来改错单……这根本不是“集成”是“人肉中转”。本篇讲的就是把「PHP发出申请 → 钉钉完成审核 → PHP自动更新信息」这条链路在真实业务中稳稳跑通的完整版。它不依赖第三方低代码平台不靠钉钉宜搭硬套而是基于钉钉开放平台标准能力审批实例回调 自建应用 服务端签名验证用纯 PHP 实现端到端闭环。适合正在维护老 PHP 系统5.6 / 7.x / 8.x 均可、需要快速对接钉钉审批但又不想推翻重做的工程师。重点不是“能连上”而是“状态不丢、幂等可靠、日志可查、出错可溯”——这才是上线后不被骂的关键。2. 审批流设计与钉钉侧配置从自建应用创建到审批模板绑定要让 PHP 发起的申请真正在钉钉里可审、可操作、可回调必须先在钉钉开放平台完成三件不可跳过的事注册自建应用、配置审批模板、绑定二者关系。这不是“点几下就完”的配置每一步都决定后续能否收到回调、能否正确解析字段、能否反向更新PHP数据。2.1 创建自建应用并获取关键凭证登录 钉钉开放平台 →「应用开发」→「自建应用」→「创建应用」。注意必须选「企业内部应用」而非「第三方企业应用」——后者面向多租户权限模型和回调机制完全不同新手极易踩坑。填写基础信息后进入应用详情页记下以下三项后续PHP代码全程依赖AppKey应用唯一标识字符串形如dingoajk1234567890AppSecret应用密钥仅首次可见务必立即复制保存丢失需重置会中断所有已上线服务CorpId你企业的唯一ID非应用ID可在「企业信息」页找到形如dingabcdef123456789提示CorpId是企业级身份锚点所有用户、部门、审批实例均归属其下。PHP后端发起审批时需携带此ID钉钉回调时也会在请求头中透传x-dingtalk-orgid即 CorpId这是校验回调来源合法性的第一道防线。2.2 创建审批模板并启用「审批实例回调」进入「工作台」→「审批」→「管理审批模板」→「新建模板」。这里的关键不是表单多好看而是字段定义必须与PHP系统字段严格对齐。例如PHP系统字段名审批模板字段名字段类型是否必填说明employee_id员工工号单行文本是PHP发起时传入用于关联用户reason申请事由多行文本是与PHP表单textarea内容一致amount金额元数字否若PHP有金额字段此处必须为数字型否则回调取值为字符串100.00导致PHP类型错误创建完成后点击模板右侧「设置」→「流程设置」→「高级设置」→勾选「审批实例回调」→ 填写你的PHP服务器公网可访问的回调地址如https://your-php-app.com/api/dingtalk/callback。⚠️ 注意该地址必须是 HTTPS且域名已备案国内云主机常见限制若用内网穿透如 frp/ngrok仅限测试正式环境必须用真实备案域名SSL证书。2.3 绑定应用与审批模板回到自建应用详情页 →「功能设置」→「审批」→「添加审批模板」→ 从列表中选择刚创建的模板 → 点击「授权」。此时该应用才真正获得对此模板的发起权限。未绑定前PHP调用审批创建接口会返回errcode: 50005, errmsg: invalid template—— 这是线上最常被问却最易忽略的报错。3. PHP端发起审批构造请求、签名、处理响应的最小可行代码PHP发起审批的本质是向钉钉开放平台https://oapi.dingtalk.com/topapi/processinstance/create接口发送一个带签名的 POST 请求。难点不在调用本身而在参数组织是否符合钉钉 Schema、签名逻辑是否与官方 SDK 一致、失败重试是否可控。3.1 准备依赖与配置文件无需引入庞大 SDK。我们用原生 cURL 自研签名函数完全复刻钉钉 Java SDK 的 HmacSHA256 签名逻辑避免版本兼容风险。创建config/dingtalk.php?php return [ app_key dingoajk1234567890, app_secret your_app_secret_here, corp_id dingabcdef123456789, process_code PROC-xxxxxx-xxxxxx-xxxxxx, // 在审批模板「设置」→「基础设置」中查看 callback_url https://your-php-app.com/api/dingtalk/callback, ];说明process_code是审批模板的唯一编码不是模板ID也不是应用ID。它长这样PROC-7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d。漏填或填错将导致errcode: 50005。3.2 构造审批实例请求体关键字段详解钉钉要求请求体为 JSON且formComponentValues字段必须是严格按审批模板字段顺序排列的数组非关联数组。PHP中极易因json_encode()默认行为导致结构错误。正确写法?php // 引入配置 $config require config/dingtalk.php; // 拟提交数据来自PHP表单POST $data [ employee_id $_POST[employee_id] ?? , reason $_POST[reason] ?? , amount (float)($_POST[amount] ?? 0), ]; // 构造 formComponentValues必须是索引数组且字段名、类型、顺序与钉钉模板完全一致 $form_values [ [name 员工工号, value $data[employee_id]], // name审批模板中字段「标题」非字段名 [name 申请事由, value $data[reason]], [name 金额元, value $data[amount]], ]; // 完整请求体 $payload [ process_code $config[process_code], originator_user_id manager123, // 发起人钉钉UserID需提前通过免登或通讯录API获取 dept_id 123456789, // 发起人所在部门ID同上 approvers [[userid approver456]], // 审批人列表userid为钉钉真实UserID cc_list [[userid ccuser789]], // 抄送人可选 form_component_values $form_values, ]; // 转JSON禁止Unicode转义钉钉不认 \u4f60\u597d $json_body json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);关键点form_component_values中的name必须填审批模板里字段的显示标题如“员工工号”不是你在模板编辑器里设的字段标识符如employee_id。填错则字段为空且无报错提示极难排查。3.3 实现 HmacSHA256 签名与请求发送钉钉签名规则base64(hmac_sha256(app_secret, http_method \n content-md5 \n content-type \n canonicalized_headers \n canonicalized_resource))。我们封装为函数?php function buildDingTalkSignature($method, $url, $body, $appSecret, $headers []) { $contentMd5 base64_encode(md5($body, true)); $contentType application/json; charsetutf-8; // canonicalized_headers只取以 x-dingtalk- 开头的头小写排序拼接 $canonicalizedHeaders ; $dingHeaders []; foreach ($headers as $key $val) { if (stripos($key, x-dingtalk-) 0) { $dingHeaders[strtolower($key)] trim($val); } } ksort($dingHeaders); foreach ($dingHeaders as $k $v) { $canonicalizedHeaders . $k . : . $v . \n; } // canonicalized_resourceURL path query string不含host $parsed parse_url($url); $canonicalizedResource $parsed[path] . ($parsed[query] ?? ); $stringToSign strtoupper($method) . \n . $contentMd5 . \n . $contentType . \n . $canonicalizedHeaders . $canonicalizedResource; $signature base64_encode(hash_hmac(sha256, $stringToSign, $appSecret, true)); return $signature; } // 发送请求 $apiUrl https://oapi.dingtalk.com/topapi/processinstance/create?access_token . getAccessToken($config); // access_token获取见下文 $headers [ Content-Type: application/json; charsetutf-8, Accept: application/json, ]; $signature buildDingTalkSignature(POST, $apiUrl, $json_body, $config[app_secret], $headers); $ch curl_init(); curl_setopt_array($ch, [ CURLOPT_URL $apiUrl, CURLOPT_POST true, CURLOPT_POSTFIELDS $json_body, CURLOPT_HTTPHEADER array_merge($headers, [Authorization: hmac_sha256 . $signature]), CURLOPT_RETURNTRANSFER true, CURLOPT_TIMEOUT 30, ]); $response curl_exec($ch); $httpCode curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode 200) { $result json_decode($response, true); if (isset($result[errcode]) $result[errcode] 0) { $instanceId $result[process_instance_id]; // 钉钉返回的唯一审批实例ID saveToDb($data, $instanceId); // 保存到PHP数据库关联实例ID用于后续回调匹配 echo json_encode([success true, instance_id $instanceId]); } else { error_log(DingTalk API Error: . print_r($result, true)); echo json_encode([success false, msg 审批创建失败]); } } else { error_log(HTTP Error $httpCode: . $response); echo json_encode([success false, msg 网络请求失败]); }注意getAccessToken()需自行实现调用/gettoken接口且必须做本地缓存有效期2小时避免高频请求触发限流。不要每次发起都重新取token。4. 钉钉回调接收与PHP状态更新幂等处理、签名验证、字段映射钉钉在审批完成通过/拒绝/转交/终止后会向你配置的callback_url发送 POST 请求。这不是“通知”而是带完整审批结果的权威数据源。PHP必须严格验证、解析、落库且一次回调可能重复送达网络抖动必须幂等。4.1 回调入口强制验证签名与来源创建api/dingtalk/callback.php第一件事不是解析JSON而是验签?php // api/dingtalk/callback.php $config require ../config/dingtalk.php; // 1. 获取原始body不能用 $_POST钉钉发的是 raw json $rawBody file_get_contents(php://input); if (empty($rawBody)) { http_response_code(400); exit(Empty body); } // 2. 获取关键header $timestamp $_SERVER[HTTP_X_DINGTALK_TIMESTAMP] ?? ; $sign $_SERVER[HTTP_X_DINGTALK_SIGNATURE] ?? ; $orgId $_SERVER[HTTP_X_DINGTALK_ORGID] ?? ; // 3. 验证 orgId 是否匹配防伪造 if ($orgId ! $config[corp_id]) { error_log(Invalid orgId: $orgId); http_response_code(403); exit(Forbidden); } // 4. 验证 timestamp15分钟内有效 $now time() * 1000; if (abs($now - (int)$timestamp) 900000) { // 15min error_log(Timestamp expired: $timestamp); http_response_code(400); exit(Bad Request); } // 5. 验证 signatureHmacSHA256(timestamp \n rawBody, app_secret) $expectedSign base64_encode(hash_hmac(sha256, $timestamp . \n . $rawBody, $config[app_secret], true)); if (!hash_equals($expectedSign, $sign)) { error_log(Signature mismatch); http_response_code(403); exit(Forbidden); } // 6. 解析JSON $data json_decode($rawBody, true); if (json_last_error() ! JSON_ERROR_NONE) { error_log(Invalid JSON: . $rawBody); http_response_code(400); exit(Bad Request); } // 至此数据可信开始业务处理 handleDingTalkCallback($data);提示hash_equals()是PHP内置安全比较函数防止时序攻击。不用直接比对。4.2 解析回调数据并映射到PHP字段钉钉回调结构复杂关键路径如下精简后{ EventType: bpms_instance_change, processInstanceId: abcd1234-ef56-gh78-ij90-klmnopqrstuv, result: agree, // agree / refuse / terminate / transfer tasks: [{ taskId: task_abc123, status: FINISHED, result: agree, operatorUserId: approver456 }], formInst: { formValues: [ {name: 员工工号, value: EMP2023001}, {name: 申请事由, value: 购买开发笔记本}, {name: 金额元, value: 8500.00} ] } }PHP解析与映射逻辑?php function handleDingTalkCallback($data) { global $config; // 仅处理审批实例变更事件 if ($data[EventType] ! bpms_instance_change) { return; } $instanceId $data[processInstanceId] ?? ; $result $data[result] ?? unknown; // 1. 根据 instanceId 查询PHP本地记录必须有索引 $record findRecordByInstanceId($instanceId); if (!$record) { error_log(No local record for instanceId: $instanceId); return; // 丢弃可能是测试数据或过期回调 } // 2. 幂等判断仅当当前状态为 pending 时才更新 if ($record[status] ! pending) { error_log(Skip update: already {$record[status]} for {$instanceId}); return; } // 3. 映射审批结果到PHP状态 $phpStatusMap [ agree approved, refuse rejected, terminate cancelled, transfer transferred ]; $newStatus $phpStatusMap[$result] ?? unknown; // 4. 提取表单值注意formInst.formValues 是数组需按name找value $formValues $data[formInst][formValues] ?? []; $formMap []; foreach ($formValues as $fv) { $formMap[$fv[name]] $fv[value] ?? ; } // 5. 更新PHP数据库示例SQL $pdo getDbConnection(); $stmt $pdo-prepare( UPDATE php_approval_records SET status ?, approved_at NOW(), approver_userid ?, remark ?, employee_id ?, reason ?, amount ? WHERE instance_id ? ); $stmt-execute([ $newStatus, $data[tasks][0][operatorUserId] ?? null, $data[result] refuse ? ($formMap[审批意见] ?? 无) : null, $formMap[员工工号] ?? $record[employee_id], $formMap[申请事由] ?? $record[reason], (float)($formMap[金额元] ?? 0), $instanceId ]); // 6. 记录审计日志强烈建议 logAuditEvent($instanceId, $newStatus, $data); }关键细节formInst.formValues中的name是字段标题如“员工工号”与发起时一致value类型取决于模板字段类型——数字字段返回字符串8500.00需(float)转换日期字段返回2023-10-01字符串PHP可直接存。5. 避坑指南5个血泪经验总结的线上高频问题与解法线上跑通不等于稳定运行。以下是某开发者在三个不同PHP系统人事/IT工单/采购中踩过的坑每一条都附带现象、根因和可立即落地的解法。5.1 现象PHP发起审批成功但钉钉App里看不到新审批单原因originator_user_id或dept_id填写错误或该用户未在钉钉通讯录中激活。钉钉静默丢弃请求返回errcode: 0伪成功。解法在发起前用钉钉通讯录APIhttps://oapi.dingtalk.com/topapi/v2/user/get校验originator_user_id是否存在且isActivated:truedept_id必须是整数不能是字符串123456789PHP中强转(int)日志中打印originator_user_id和dept_id与钉钉管理后台「通讯录」页面比对。5.2 现象回调收到但formInst.formValues为空数组原因审批模板中字段设置了「仅发起人可见」或「仅审批人可见」而回调数据默认只返回「所有人可见」的字段。解法进入审批模板编辑 → 点击每个字段 → 「可见范围」设为「所有人」或改用formInst.allFormValues钉钉V5.0支持它返回全部字段值无论可见性。5.3 现象同一审批单收到2次完全相同的回调原因钉钉为保证投递可靠性会在超时默认3秒未收到200响应时重试。PHP脚本执行超时或网络延迟导致。解法回调入口脚本开头加set_time_limit(10)确保10秒内返回200所有耗时操作如DB更新、发邮件放入异步队列Redis Worker入口只做验签入库任务队列数据库表加唯一索引UNIQUE KEY idx_instance_id (instance_id)INSERT IGNORE或ON DUPLICATE KEY UPDATE。5.4 现象审批通过后PHP数据库状态仍是pending原因回调中findRecordByInstanceId()查询慢或未加索引导致超时失败或$record[status]字段在DB中为NULL! pending判断为truePHP NULL ! pending 为 true。解法php_approval_records.instance_id字段必须加 B-tree 索引状态字段用ENUM(pending,approved,rejected) DEFAULT pending避免NULL回调中加error_log(Found record: . print_r($record, true));确认查询结果。5.5 现象x-dingtalk-signature验证失败但手动计算签名一致原因PHPfile_get_contents(php://input)在某些服务器如NginxPHP-FPM配置下若请求头含Transfer-Encoding: chunked会读取为空。解法改用file_get_contents(php://input)前先检查$_SERVER[CONTENT_LENGTH]更可靠方式$rawBody isset($_GLOBALS[HTTP_RAW_POST_DATA]) ? $_GLOBALS[HTTP_RAW_POST_DATA] : file_get_contents(php://input);或在Nginx配置中加underscores_in_headers on;并确保fastcgi_param HTTP_X_DINGTALK_SIGNATURE $http_x_dingtalk_signature;。6. 进阶技巧用「审批快照」解决字段变更与历史追溯难题上线后很快会遇到新需求老板说“审批模板加个‘紧急程度’下拉框”但老数据没这个字段PHP页面展示时undefined index或者法务要求“所有审批单必须保留发起时的原始表单快照哪怕模板改了也要能查到当时填了什么”。这时候光靠回调里的formInst.formValues不够——它只反映当前模板结构。6.1 方案在PHP发起时主动存一份「审批快照」原理很简单PHP构造form_component_values时已掌握所有原始值。与其等回调再解析不如在发起成功后立刻将$form_values或更干净的$data序列化存入数据库快照表CREATE TABLE php_approval_snapshots ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, instance_id varchar(64) NOT NULL COMMENT 钉钉审批实例ID, snapshot_data longtext NOT NULL COMMENT JSON原始表单数据, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_instance_id (instance_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;发起审批成功后// ... 上文 $json_body 构造后发起前 $snapshotData [ employee_id $data[employee_id], reason $data[reason], amount $data[amount], submit_time date(Y-m-d H:i:s), php_version v2.1, // 标记PHP系统版本便于后续迁移 ]; insertSnapshot($instanceId, $snapshotData); // 存入快照表6.2 查询时优先读快照 fallback 到回调数据当用户在PHP后台查看某审批详情时function getApprovalDetail($instanceId) { // 1. 优先查快照100%准确含所有发起时字段 $snapshot getSnapshotByInstanceId($instanceId); if ($snapshot) { return $snapshot; } // 2. 快照不存在如老数据再查最新回调数据可能字段不全 $latestCallback getLatestCallbackByInstanceId($instanceId); if ($latestCallback) { return $latestCallback; } // 3. 都没有返回空数组或抛异常 return []; }6.3 表格对比快照 vs 回调数据的适用场景场景快照数据发起时存回调数据钉钉推送推荐使用展示“员工当时填了什么”✅ 完整、原始、无歧义⚠️ 可能缺失字段模板变更后快照获取“谁在什么时候审批的”❌ 不包含审批人信息✅ 含operatorUserId,result,tasks回调审计追溯法律合规✅ 时间戳精确到秒不可篡改✅ 含钉钉时间戳但依赖网络两者都要存模板字段动态增减✅ 旧快照仍可用❌ 新字段在旧回调中不存在快照为主回调补审批动作我在线上所有项目都强制开启快照。不是为了炫技而是某次法务突击检查要求提供3个月前某采购单的原始申请理由——没有快照只能翻备份库、写临时脚本、加班两小时。有了快照SELECT snapshot_data FROM php_approval_snapshots WHERE instance_id xxx10秒搞定。这10秒就是深夜不用爬起来改bug的底气。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑