PHP对接钉钉审批流:从发起到回调回写的完整闭环实现

发布时间:2026/10/14 1:52:30
PHP对接钉钉审批流:从发起到回调回写的完整闭环实现 简介本资源是一套基于PHP与钉钉开放平台深度集成的完整审批流实现方案面向中高级PHP开发者及企业级应用系统维护人员解决PHP后端主动发起审批申请、对接钉钉审核流程、同步更新业务数据等核心问题。压缩包为21KB的ZIP文件共含12个PHP脚本涵盖数据库连接get_db_conn.php、用户登录鉴权UserLogin.php、钉钉加解密DingtalkCrypt.php、pkcs7Encoder.php、sha1.php、审批请求封装send_apply.php、部门/基础数据获取get_dept.php、basic_data.php、错误码管理errorCode.php及全流程测试入口test.php、PrCreate.php等关键模块结构清晰、职责分明便于快速嵌入现有系统。目前已有193人学习下载提供开箱即用的接口调用逻辑、标准化的数据组装方式与典型异常处理机制特别适合需要快速对接钉钉OA审批、构建内外协同业务流程的中小型项目团队。1. 钉钉审批流与 PHP 后端联动不是调个 API 就完事而是打通「申请—审核—回写」闭环你有没有遇到过这种场景用户在 PHP 系统里点一下「提交报销」状态却卡在「待审批」钉钉里流程走完了PHP 数据库里的status字段还是0或者更糟——审批人点「通过」后PHP 端压根没收到通知财务还得手动去后台改数据这不是接口没通是整个审批闭环断在了「结果回写」这一环。本资源是一套经过某公司真实产线验证的 PHP 钉钉审批集成方案覆盖从 PHP 主动发起审批单含附件、多级审批人、自定义表单字段、监听钉钉回调事件审批通过/拒绝/转交、解析加密签名、校验事件合法性到最终安全更新 PHP 业务数据库的完整链路。它不依赖任何第三方 SDK 封装黑匣子所有核心逻辑用原生 cURL OpenSSL JSON 实现代码可调试、参数可追踪、错误可定位。适合正在做 OA 对接、HR 系统升级、或需要将钉钉审批深度嵌入自有 PHP 业务流的开发者——尤其当你已经踩过「回调收不到」「签名验不过」「审批人 ID 对不上」这类坑时这份资源就是你缺的那块拼图。2. 审批发起用 PHP 构造合规的 dingtalk.openapi.approval.create 请求要让钉钉真正“认”你的审批单光填个 title 和 content 远远不够。钉钉审批 API 对请求体结构、时间戳、签名方式、审批模板 ID 绑定有强约束。本节带你手写一个可复用的审批发起类绕过 SDK 封装带来的参数不可见问题。2.1 审批模板 ID 获取与字段映射确认钉钉审批流必须绑定一个已发布的「审批模板」其 IDprocess_code不是后台 URL 里的数字而是形如PROC-XXXXX-XXXXX-XXXXX的字符串。切记这个 ID 必须在钉钉管理后台「工作台 审批 模板管理」中复制「模板编码」而非「模板ID」或「URL 参数」。获取后需人工核对模板内字段与 PHP 提交数据的映射关系。例如模板中「报销金额」字段类型为number对应formComponentValues中的value必须是数字非字符串1200.00「附件」字段类型为attachment其value是钉钉上传后返回的media_id列表非本地文件路径「申请人部门」若为组织架构字段value应为部门 ID如123456789而非部门名称。提示钉钉模板字段 IDcomponentId在模板编辑页「高级设置 字段属性」中可见PHP 提交时formComponentValues的name必须与此完全一致大小写敏感。2.2 构造带签名的 POST 请求体钉钉开放平台要求所有写操作包括创建审批单必须携带access_token且该 token 需通过app_keyapp_secret调用/gettoken接口获取。关键点在于token 有效期 2 小时但 PHP 进程不能每次请求都重新获取。本方案采用内存缓存 时间戳校验策略?php class DingTalkApprovalSender { private $appKey your_app_key_here; private $appSecret your_app_secret_here; private $tokenCacheFile /tmp/dt_access_token.cache; private function getAccessToken() { // 先读缓存 if (file_exists($this-tokenCacheFile)) { $cache json_decode(file_get_contents($this-tokenCacheFile), true); if ($cache $cache[expires_at] time()) { return $cache[access_token]; } } // 缓存失效或不存在调用接口 $url https://oapi.dingtalk.com/gettoken?appkey{$this-appKey}appsecret{$this-appSecret}; $response json_decode(file_get_contents($url), true); if (!isset($response[access_token])) { throw new Exception(Failed to get access token: . json_encode($response)); } // 写入缓存有效期 110 分钟预留 10 分钟缓冲 $cacheData [ access_token $response[access_token], expires_at time() 110 * 60 ]; file_put_contents($this-tokenCacheFile, json_encode($cacheData)); return $response[access_token]; } }逻辑说明$this-tokenCacheFile使用绝对路径如/tmp/避免因 PHP 运行用户权限导致写入失败expires_at设为time() 110 * 60110 分钟而非官方文档写的 120 分钟是为网络延迟和时钟漂移留出安全余量若file_get_contents($url)失败如 DNS 解析超时需捕获false并抛出异常否则后续请求必 401。2.3 组装审批单并发送构造请求体时process_code、originator_user_id申请人钉钉 ID、dept_id申请人部门 ID为必填项。formComponentValues是核心其结构必须严格匹配模板字段?php public function createApproval($approvalData) { $accessToken $this-getAccessToken(); $url https://oapi.dingtalk.com/topapi/processinstance/create?access_token{$accessToken}; // 注意钉钉要求 originator_user_id 是用户在当前企业的唯一标识非手机号、非邮箱 // 必须通过企业内部通讯录接口 /user/get_by_mobile 或 /user/get 获取 $payload [ process_code PROC-XXXXX-XXXXX-XXXXX, // 替换为实际模板编码 originator_user_id $approvalData[originator_id], // 钉钉用户ID dept_id $approvalData[dept_id], // 部门ID approvers [ // 审批人列表支持多个 [userid $approvalData[approver1_id]], [userid $approvalData[approver2_id]] ], cc_list [$approvalData[cc_user_id]], // 抄送人 form_component_values [ [ name amount, // 模板中「报销金额」字段的 componentId value (float)$approvalData[amount] // 强制转 float避免字符串导致验签失败 ], [ name description, value htmlspecialchars($approvalData[desc], ENT_QUOTES, UTF-8) // 防 XSS ], [ name attachments, value json_encode($approvalData[media_ids]) // media_id 数组需 JSON 编码为字符串 ] ] ]; $ch curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); curl_setopt($ch, CURLOPT_HTTPHEADER, [ Content-Type: application/json; charsetutf-8 ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 30); $response curl_exec($ch); $httpCode curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode ! 200) { throw new Exception(DingTalk API error {$httpCode}: {$response}); } $result json_decode($response, true); if (!isset($result[process_instance_id])) { throw new Exception(Failed to create approval: . json_encode($result)); } return $result[process_instance_id]; // 返回钉钉生成的实例ID用于后续查询 }参数说明approvers是二维数组每个元素是一个[userid xxx]表示一级审批人若需会签可传多个同级若需串签需在钉钉模板中配置审批路线form_component_values中value的类型必须与模板字段类型严格一致number → float/inttext → stringattachment → JSON string of arraycurl_setopt($ch, CURLOPT_TIMEOUT, 30)设为 30 秒避免因钉钉侧响应慢导致 PHP 进程长时间阻塞htmlspecialchars()对描述字段转义防止用户输入script等恶意内容污染钉钉审批页。3. 回调监听接收钉钉事件并完成 PHP 数据库回写审批单被处理后钉钉会向你配置的「事件订阅 URL」推送 HTTPS POST 请求。这不是简单的 Webhook而是包含 AES 加密、签名验证、事件解密三重关卡的硬核流程。跳过任一环节你的 PHP 就永远收不到「已通过」通知。3.1 钉钉事件订阅配置与加解密密钥在钉钉开放平台「应用开发 企业内部应用 事件订阅」中需配置订阅 URL你的 PHP 接收脚本地址如https://yourdomain.com/dt-callback.php必须是 HTTPSToken自定义字符串如my_dingtalk_token用于生成签名AES Key32 位 Base64 字符串如ZmRjYzQyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg由钉钉生成务必保存好丢失无法解密加密类型选择AES非明文。注意AES Key 是二进制密钥经 Base64 编码后的字符串PHP 解密时需先base64_decode()还原为 32 字节原始密钥。3.2 完整回调入口脚本dt-callback.php此脚本是整个闭环的神经中枢必须能独立运行、无框架依赖、日志完备?php // dt-callback.php define(DT_TOKEN, my_dingtalk_token); define(DT_AES_KEY, ZmRjYzQyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg); define(DT_ENCODING_AES_KEY, base64_decode(DT_AES_KEY)); // 解码为二进制密钥 define(LOG_FILE, /var/log/dingtalk/callback.log); function logToFile($msg) { $timestamp date(Y-m-d H:i:s); file_put_contents(LOG_FILE, [{$timestamp}] {$msg}\n, FILE_APPEND | LOCK_EX); } // 1. 验证签名钉钉要求sha256 token timestamp nonce $signature $_GET[signature] ?? ; $timestamp $_GET[timestamp] ?? ; $nonce $_GET[nonce] ?? ; if (empty($signature) || empty($timestamp) || empty($nonce)) { http_response_code(400); echo Missing signature/timestamp/nonce; exit; } $expectedSignature hash_hmac(sha256, $timestamp . $nonce . DT_TOKEN, DT_TOKEN); if ($signature ! $expectedSignature) { logToFile(Signature mismatch: got {$signature}, expected {$expectedSignature}); http_response_code(401); echo Invalid signature; exit; } // 2. 获取并解密 POST 数据AES-256-CBC $input file_get_contents(php://input); if (!$input) { logToFile(Empty POST body); http_response_code(400); echo Empty body; exit; } // 钉钉加密格式前 16 字节 IV后为密文 $iv substr($input, 0, 16); $ciphertext substr($input, 16); // AES-256-CBC 解密 $decrypted openssl_decrypt($ciphertext, AES-256-CBC, DT_ENCODING_AES_KEY, OPENSSL_RAW_DATA, $iv); if ($decrypted false) { logToFile(AES decrypt failed: . openssl_error_string()); http_response_code(400); echo Decrypt failed; exit; } // 3. 解析 XML钉钉事件为 XML 格式非 JSON $xml simplexml_load_string($decrypted); if ($xml false) { logToFile(XML parse failed: . $decrypted); http_response_code(400); echo Invalid XML; exit; } // 4. 提取关键事件字段 $eventType (string)$xml-EventType; $processInstanceId (string)$xml-ProcessInstanceID; $status (string)$xml-Status; // agree / refuse / redirect logToFile(Received event: {$eventType}, instance: {$processInstanceId}, status: {$status}); // 5. 根据事件类型执行业务逻辑此处仅示意实际需查库、更新状态、发通知等 if ($eventType bpms_instance_change $status agree) { updatePhpDatabase($processInstanceId, approved); } elseif ($eventType bpms_instance_change $status refuse) { updatePhpDatabase($processInstanceId, rejected); } // 6. 返回 success 响应钉钉要求否则重试 echo success; exit; function updatePhpDatabase($instanceId, $newStatus) { // 示例根据 process_instance_id 关联到你的业务表 // SELECT * FROM approval_records WHERE dt_instance_id ? // UPDATE approval_records SET status ?, updated_at NOW() WHERE dt_instance_id ? // 此处替换为你的 PDO/MySQLi 实际操作 $pdo new PDO(mysql:hostlocalhost;dbnameyour_db, user, pass); $stmt $pdo-prepare(UPDATE approval_records SET status ?, updated_at NOW() WHERE dt_instance_id ?); $stmt-execute([$newStatus, $instanceId]); logToFile(DB updated for {$instanceId} - {$newStatus}); }逻辑说明hash_hmac(sha256, ...)是钉钉签名算法必须用sha256不能用md5或sha1openssl_decrypt()的OPENSSL_RAW_DATA标志必须设置否则解密失败$xml-EventType是钉钉事件类型bpms_instance_change表示审批实例状态变更这是你最关心的事件updatePhpDatabase()是占位函数实际需对接你的业务表结构强烈建议在此处加事务和幂等判断同一ProcessInstanceID可能因网络问题重复推送。3.3 事件字段解析与业务映射表钉钉推送的 XML 字段名固定但含义需结合业务理解。以下是关键字段对照表XML 字段名类型说明PHP 业务映射建议EventTypestring事件类型如bpms_instance_change仅处理此类型忽略check_url等测试事件ProcessInstanceIDstring钉钉生成的唯一审批实例ID作为关联 PHP 业务表的外键dt_instance_idStatusstringagree通过、refuse拒绝、redirect转交直接映射为status字段值approved/rejectedResultstring审批意见用户填写的「同意理由」存入approval_comment字段需htmlspecialchars()转义CreateTimestring时间戳毫秒如1623456789000转为date(Y-m-d H:i:s, $ts/1000)存入approved_at提示CreateTime是毫秒级时间戳PHPdate()函数只接受秒级务必除以 1000。4. 避坑指南五个血泪经验总结的高频翻车点钉钉审批集成最折磨人的不是代码而是那些文档没写、报错不明确、但会让你调试三天的隐藏陷阱。以下是某公司产线实测中踩出的五个典型坑按「现象 → 原因 → 解决」结构整理每一条都配了可直接复用的验证方法。4.1 现象回调 URL 测试一直失败钉钉控制台显示「验证失败」原因钉钉在首次配置事件订阅时会向你的 URL 发送一个EventTypecheck_url的 GET 请求要求返回success字符串。但你的脚本可能只处理 POST或对 GET 请求返回了 HTML 页面、404、甚至重定向。解决在dt-callback.php开头添加强制 GET 验证逻辑if ($_SERVER[REQUEST_METHOD] GET isset($_GET[echostr])) { // 钉钉验证 URL 时的 GET 请求 echo $_GET[echostr]; exit; }验证方法在浏览器访问https://yourdomain.com/dt-callback.php?echostrtest123应直接输出test123无任何额外字符或换行。4.2 现象PHP 收到回调但simplexml_load_string()返回 false日志里是乱码原因钉钉加密后的 XML 数据是二进制流file_get_contents(php://input)读取后未正确处理编码或openssl_decrypt()的 IV 提取错误长度非 16 字节。解决在解密前打印strlen($input)和bin2hex(substr($input,0,20))确认总长 ≥16 且前 16 字节是 IV。修正解密代码if (strlen($input) 16) { logToFile(Input too short: . strlen($input)); exit; } $iv substr($input, 0, 16); $ciphertext substr($input, 16);验证方法用openssl_encrypt()在本地模拟加密再用相同 IV 和密钥解密确保能还原原文。4.3 现象审批通过后PHP 数据库状态没更新但日志显示Received event...原因updatePhpDatabase()中的 SQL 语句未匹配到记录因为ProcessInstanceID在 PHP 侧存储时被截断或格式错误。钉钉的ProcessInstanceID是 32 位以上字符串如i-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx而你的数据库字段可能是VARCHAR(32)。解决将业务表中存储钉钉实例 ID 的字段改为VARCHAR(64)或TEXT并检查插入时是否用了trim()去除了不可见字符。验证方法在日志中打印strlen($processInstanceId)确认长度 32执行SELECT LENGTH(dt_instance_id) FROM approval_records WHERE id1查看字段实际长度。4.4 现象同一个审批单PHP 收到两次agree事件原因钉钉为保证投递可靠性会对未及时响应的回调进行重试间隔约 1s、3s、9s。若你的脚本执行时间 1s如数据库慢查询、网络请求阻塞钉钉会认为失败并重发。解决在updatePhpDatabase()前加幂等判断$stmt $pdo-prepare(SELECT id FROM approval_records WHERE dt_instance_id ? AND status IN (approved,rejected)); $stmt-execute([$instanceId]); if ($stmt-fetch()) { logToFile(Duplicate event for {$instanceId}, skipped); return; // 已处理过直接退出 }验证方法手动在数据库中将某条记录status改为approved再用curl模拟推送相同ProcessInstanceID的 XML观察日志是否跳过。4.5 现象审批人点击「通过」但钉钉提示「审批人无权限」PHP 却收到了agree原因originator_user_id申请人和approvers中的userid必须属于同一企业并且该用户在钉钉后台「通讯录」中状态为「在职」。若用测试账号如离职员工、外部联系人发起钉钉前端会拦截但事件仍可能推送。解决在发起审批前调用钉钉/user/get接口校验originator_user_id和approver_userid是否有效$userUrl https://oapi.dingtalk.com/user/get?access_token{$token}userid{$userid}; $userResp json_decode(file_get_contents($userUrl), true); if (!isset($userResp[userid]) || $userResp[state] ! active) { throw new Exception(Invalid or inactive user: {$userid}); }验证方法用 Postman 访问/user/get传入待校验的userid检查返回的state字段是否为active。5. 安全加固与生产环境部署 checklist把功能跑通只是第一步放到生产环境必须面对并发、重放、注入、密钥泄露等现实问题。这里不讲理论只列 7 条我在线上系统强制执行的硬性规则每一条都来自真实故障复盘。5.1 回调 URL 必须启用双向证书认证mTLS钉钉虽不强制但攻击者可伪造 POST 请求向你的回调地址发包。解决方案是在 Nginx/Apache 上配置客户端证书验证仅允许钉钉官方 IP 段100.64.0.0/10,100.127.0.0/16且持有钉钉 CA 签发证书的请求通过。配置要点Nginxlocation /dt-callback.php { ssl_client_certificate /path/to/dingtalk-ca.crt; ssl_verify_client on; ssl_verify_depth 2; # 其他 proxy_pass 配置... }提示钉钉 CA 证书需从钉钉开放平台下载每年更新必须监控过期时间。5.2 所有钉钉用户 ID 必须二次校验禁止直接入库originator_user_id和approvers.userid是钉钉分配的字符串但攻击者可篡改请求体。必须在 PHP 侧调用/user/get接口反查且只信任返回中的userid字段忽略mobile、email等可伪造字段。校验缓存时间设为 5 分钟避免频繁调用。5.3 AES 密钥绝不硬编码必须由运维注入DT_AES_KEY不能写在 PHP 文件里。生产环境使用环境变量# 启动 PHP-FPM 前 export DT_AES_KEYZmRjYzQyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2NzgPHP 中读取define(DT_AES_KEY, $_ENV[DT_AES_KEY] ?? getenv(DT_AES_KEY));血泪经验某次代码误提交到 GitHub硬编码密钥泄露导致攻击者可解密所有历史审批数据。5.4 审批单创建与回调处理必须分离进程createApproval()是同步阻塞操作而回调处理需高可用。必须将回调逻辑拆为独立的守护进程如用 Supervisor 管理的 PHP CLI 脚本通过 Redis 队列接收事件避免 Web 服务器超时或崩溃影响事件消费。队列伪代码// dt-callback.php 中 $redis new Redis(); $redis-connect(127.0.0.1, 6379); $redis-lPush(dingtalk_events, $decrypted); // 入队 echo success; // 立即返回 // worker.php 中常驻进程 while (true) { $eventXml $redis-rPop(dingtalk_events); if ($eventXml) processEvent($eventXml); usleep(100000); // 100ms 间隔 }5.5 数据库更新必须加行级锁防并发冲突当多个审批人同时操作同一单如转交通过或钉钉重试导致并发更新UPDATE approval_records SET status?可能覆盖彼此。正确做法// 先 SELECT FOR UPDATE 锁定行 $stmt $pdo-prepare(SELECT id, status FROM approval_records WHERE dt_instance_id ? FOR UPDATE); $stmt-execute([$instanceId]); $row $stmt-fetch(PDO::FETCH_ASSOC); if (!$row) { logToFile(Record not found for {$instanceId}); return; } // 检查当前状态是否允许更新如已通过则不再处理拒绝 if ($row[status] approved $newStatus rejected) { logToFile(Skip rejected: already approved); return; } // 执行更新 $stmt $pdo-prepare(UPDATE approval_records SET status ?, updated_at NOW() WHERE id ?); $stmt-execute([$newStatus, $row[id]]);5.6 日志必须结构化且包含可追溯的 trace_id所有关键操作发起审批、收到回调、DB 更新的日志必须包含唯一trace_id便于全链路排查。生成方式$traceId dt- . date(ymd) . - . str_pad(rand(0, 9999), 4, 0, STR_PAD_LEFT) . - . uniqid(, true); logToFile([{$traceId}] Create approval for user: {$userId});从那以后我每次上线新版本都强制走一遍「模拟审批→查日志→按 trace_id 追踪全链路」哪怕多花 10 分钟。线上问题 80% 能在 5 分钟内定位到是 PHP 还是钉钉侧的问题。希望帮到你。本文还有配套的精品资源点击获取