ffi 是真正的 FFI 实例,方法是 FFI::cdef 动态注入。 * * @method \FFI\CData NewSdk() * @method int Init(\FFI\CData $sdk, string $corpid, string $secret) * @method void DestroySdk(\FFI\CData $sdk) * @method int GetChatData(\FFI\CData $sdk, int $seq, int $limit, string $proxy, string $passwd, int $timeout, \FFI\CData $out) * @method int DecryptData(string $encryptKey, string $encryptMsg, \FFI\CData $out) * @method int GetMediaData(\FFI\CData $sdk, string $indexbuf, string $sdkFileid, string $proxy, string $passwd, int $timeout, \FFI\CData $out) * @method \FFI\CData NewSlice() * @method void FreeSlice(\FFI\CData $slice) * @method \FFI\CData GetContentFromSlice(\FFI\CData $slice) * @method int GetSliceLen(\FFI\CData $slice) * @method \FFI\CData NewMediaData() * @method void FreeMediaData(\FFI\CData $md) * @method \FFI\CData GetOutIndexBuf(\FFI\CData $md) * @method \FFI\CData GetData(\FFI\CData $md) * @method int GetIndexLen(\FFI\CData $md) * @method int GetDataLen(\FFI\CData $md) * @method int IsMediaDataFinish(\FFI\CData $md) */ interface WeComFinanceSdkBinding { } /** * 企业微信「会话内容存档」官方 C SDK 的 PHP FFI 封装 * * @see https://developer.work.weixin.qq.com/document/path/91774 会话存档开发接入指南 * * 官方动态库(需自行下载后放到服务器): * Linux: libWeWorkFinanceSdk_C.so * Windows: WeWorkFinanceSdk.dll * * 下载地址:https://developer.work.weixin.qq.com/document/path/91883 * * 关键点: * 1. 调用 GetChatData 获取一批 encrypt_random_key + encrypt_chat_msg * 2. encrypt_random_key 是 RSA 公钥加密的,需用企业后台上传公钥对应的私钥解出 AES 密钥 * 3. AES 密钥 + encrypt_chat_msg 调 DecryptData 得到最终明文 JSON * 4. 消息 msgtype 为 image/voice/video/file/emotion/chatrecord(含 voip_doc_share) 时需额外调 GetMediaData * 可能分片返回(is_finish=0 时带 out_index_buf 继续下一片) * * 本类对「SDK 未安装 / 未启用」场景做了 noop 兼容:构造时若 `msgaudit_enabled=false` * 或动态库路径为空,则 isAvailable() 返回 false,所有操作返回安全空结构,不抛异常。 * 上层(Archive 拉取命令)按 isAvailable() 分支。 */ class WeComFinanceSdkClient { /** @var FFI&WeComFinanceSdkBinding|null */ private $ffi = null; /** @var \FFI\CData|null NewSdk() 的返回值 */ private $sdk = null; private bool $inited = false; private bool $available = false; private string $corpId = ''; private string $secret = ''; private string $libPath = ''; private string $proxy = ''; private string $proxyPasswd = ''; private int $timeout = 60; private int $limit = 500; /** @var array 私钥 PEM,按 public_key_ver 索引(允许配置多版本轮换) */ private array $privateKeys = []; private string $lastError = ''; public function __construct() { $enabled = (bool) config('pay.wechat_work.msgaudit_enabled', false); $this->corpId = (string) config('pay.wechat_work.corp_id', ''); $this->secret = (string) config('pay.wechat_work.msgaudit_secret', ''); $this->libPath = (string) config('pay.wechat_work.msgaudit_sdk_lib_path', ''); $this->proxy = (string) config('pay.wechat_work.msgaudit_proxy', ''); $this->proxyPasswd = (string) config('pay.wechat_work.msgaudit_proxy_passwd', ''); $this->timeout = (int) config('pay.wechat_work.msgaudit_timeout', 60); $this->limit = (int) config('pay.wechat_work.msgaudit_limit', 500); $this->privateKeys = $this->loadPrivateKeys(); if (!$enabled) { $this->lastError = 'msgaudit not enabled'; return; } if ($this->corpId === '' || $this->secret === '' || $this->libPath === '') { $this->lastError = 'msgaudit config incomplete (corp_id / msgaudit_secret / msgaudit_sdk_lib_path)'; return; } if (!extension_loaded('ffi')) { $this->lastError = 'php ffi extension not loaded'; return; } if (!is_file($this->libPath)) { $this->lastError = 'msgaudit sdk lib not found: ' . $this->libPath; return; } if (empty($this->privateKeys)) { $this->lastError = 'msgaudit private key not configured'; return; } try { $this->ffi = FFI::cdef($this->cdef(), $this->libPath); $this->sdk = $this->ffi->NewSdk(); $ret = $this->ffi->Init($this->sdk, $this->corpId, $this->secret); if ($ret !== 0) { $this->lastError = 'SDK Init failed, ret=' . $ret; $this->ffi->DestroySdk($this->sdk); $this->sdk = null; return; } $this->inited = true; $this->available = true; } catch (\Throwable $e) { $this->lastError = 'FFI error: ' . $e->getMessage(); $this->sdk = null; } } public function __destruct() { try { if ($this->sdk !== null && $this->ffi !== null) { $this->ffi->DestroySdk($this->sdk); } } catch (\Throwable $e) { // 忽略析构异常 } } public function isAvailable(): bool { return $this->available; } public function getLastError(): string { return $this->lastError; } public function getTimeout(): int { return $this->timeout; } public function getLimit(): int { return $this->limit; } /** * 拉取一批消息;返回解密后的明文数组(每条含 msgid、action、msgtype、content-like 字段等) * * @param int $seq 上次拉取到的最大 seq,首次传 0 * @param int $limit 单次条数,官方上限 1000 * * @return array{ * ok: bool, * error: string, * messages: array>, * next_seq: int, * } */ public function pullChatData(int $seq = 0, ?int $limit = null): array { if (!$this->available) { return [ 'ok' => false, 'error' => $this->lastError !== '' ? $this->lastError : 'sdk unavailable', 'messages' => [], 'next_seq' => $seq, ]; } $limit = $limit === null ? $this->limit : $limit; $limit = (int) min(1000, max(1, $limit)); $slice = null; try { $slice = $this->ffi->NewSlice(); $ret = $this->ffi->GetChatData( $this->sdk, $seq, $limit, $this->proxy, $this->proxyPasswd, $this->timeout, $slice ); if ($ret !== 0) { return [ 'ok' => false, 'error' => 'GetChatData failed, ret=' . $ret, 'messages' => [], 'next_seq' => $seq, ]; } $json = $this->sliceToString($slice); $decoded = json_decode($json, true); if (!is_array($decoded) || !isset($decoded['chatdata']) || !is_array($decoded['chatdata'])) { return [ 'ok' => true, 'error' => '', 'messages' => [], 'next_seq' => $seq, ]; } $messages = []; $nextSeq = $seq; foreach ($decoded['chatdata'] as $entry) { if (!is_array($entry)) { continue; } $curSeq = isset($entry['seq']) ? (int) $entry['seq'] : 0; if ($curSeq > $nextSeq) { $nextSeq = $curSeq; } $plain = $this->decryptChatItem($entry); if ($plain === null) { continue; } $plain['__seq'] = $curSeq; $plain['__msgid_sdk'] = (string) ($entry['msgid'] ?? ($plain['msgid'] ?? '')); $plain['__publickey_ver'] = (int) ($entry['publickey_ver'] ?? 0); $messages[] = $plain; } return [ 'ok' => true, 'error' => '', 'messages' => $messages, 'next_seq' => $nextSeq, ]; } catch (\Throwable $e) { return [ 'ok' => false, 'error' => 'exception: ' . $e->getMessage(), 'messages' => [], 'next_seq' => $seq, ]; } finally { if ($slice !== null) { try { $this->ffi->FreeSlice($slice); } catch (\Throwable $e) { // ignore } } } } /** * 下载媒体(image/voice/video/file/emotion)到本地文件 * * @param string $sdkFileid 消息明文里的 sdkfileid * @param string $destPath 目标绝对路径 * * @return array{ok: bool, error: string, size: int} */ public function downloadMedia(string $sdkFileid, string $destPath): array { if (!$this->available) { return ['ok' => false, 'error' => $this->lastError !== '' ? $this->lastError : 'sdk unavailable', 'size' => 0]; } if ($sdkFileid === '') { return ['ok' => false, 'error' => 'empty sdkfileid', 'size' => 0]; } $dir = dirname($destPath); if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { return ['ok' => false, 'error' => 'mkdir failed: ' . $dir, 'size' => 0]; } $fp = @fopen($destPath, 'wb'); if ($fp === false) { return ['ok' => false, 'error' => 'fopen failed: ' . $destPath, 'size' => 0]; } $indexBuf = ''; $totalSize = 0; $chunkCount = 0; $maxChunks = 10000; // 安全上限,防止 SDK 异常时死循环 try { while ($chunkCount++ < $maxChunks) { $mediaData = $this->ffi->NewMediaData(); try { $ret = $this->ffi->GetMediaData( $this->sdk, $indexBuf, $sdkFileid, $this->proxy, $this->proxyPasswd, $this->timeout, $mediaData ); if ($ret !== 0) { fclose($fp); @unlink($destPath); return ['ok' => false, 'error' => 'GetMediaData failed, ret=' . $ret . ' chunk=' . $chunkCount, 'size' => 0]; } $dataLen = (int) $this->ffi->GetDataLen($mediaData); if ($dataLen > 0) { $dataPtr = $this->ffi->GetData($mediaData); $chunk = FFI::string($dataPtr, $dataLen); fwrite($fp, $chunk); $totalSize += strlen($chunk); } $isFinish = (int) $this->ffi->IsMediaDataFinish($mediaData); if ($isFinish === 1) { break; } $indexLen = (int) $this->ffi->GetIndexLen($mediaData); if ($indexLen > 0) { $indexPtr = $this->ffi->GetOutIndexBuf($mediaData); $indexBuf = FFI::string($indexPtr, $indexLen); } else { // 无 index 又 not finish,保护性退出 break; } } finally { $this->ffi->FreeMediaData($mediaData); } } fclose($fp); return ['ok' => true, 'error' => '', 'size' => $totalSize]; } catch (\Throwable $e) { if (is_resource($fp)) { fclose($fp); } @unlink($destPath); return ['ok' => false, 'error' => 'exception: ' . $e->getMessage(), 'size' => 0]; } } /** * 单条消息解密:encrypt_random_key(RSA)-> AES key -> DecryptData 明文 * * @return array|null */ private function decryptChatItem(array $entry): ?array { $encryptRandomKeyB64 = (string) ($entry['encrypt_random_key'] ?? ''); $encryptChatMsg = (string) ($entry['encrypt_chat_msg'] ?? ''); $publicKeyVer = (int) ($entry['publickey_ver'] ?? 0); if ($encryptRandomKeyB64 === '' || $encryptChatMsg === '') { return null; } $privateKey = $this->privateKeys[$publicKeyVer] ?? null; if ($privateKey === null) { // 尝试唯一配置的私钥兜底 $privateKey = reset($this->privateKeys) ?: null; } if ($privateKey === null) { Log::warning(sprintf('会话存档-无匹配私钥 publickey_ver=%d', $publicKeyVer)); return null; } $encryptRandomKey = base64_decode($encryptRandomKeyB64, true); if ($encryptRandomKey === false) { return null; } $aesKey = null; // 企微会话存档默认 RSA-PKCS1 填充;部分版本可能使用 OAEP,兼容尝试 if (openssl_private_decrypt($encryptRandomKey, $decrypted, $privateKey, OPENSSL_PKCS1_PADDING)) { $aesKey = $decrypted; } elseif (openssl_private_decrypt($encryptRandomKey, $decrypted, $privateKey, OPENSSL_PKCS1_OAEP_PADDING)) { $aesKey = $decrypted; } else { Log::warning(sprintf( '会话存档-RSA 解密失败 publickey_ver=%d err=%s', $publicKeyVer, (string) openssl_error_string() )); return null; } $slice = null; try { $slice = $this->ffi->NewSlice(); $ret = $this->ffi->DecryptData($aesKey, $encryptChatMsg, $slice); if ($ret !== 0) { Log::warning('会话存档-DecryptData 失败 ret=' . $ret); return null; } $plain = $this->sliceToString($slice); $json = json_decode($plain, true); if (!is_array($json)) { return null; } return $json; } finally { if ($slice !== null) { try { $this->ffi->FreeSlice($slice); } catch (\Throwable $e) { // ignore } } } } private function sliceToString(\FFI\CData $slice): string { $len = (int) $this->ffi->GetSliceLen($slice); if ($len <= 0) { return ''; } $buf = $this->ffi->GetContentFromSlice($slice); return FFI::string($buf, $len); } /** * @return array publickey_ver => pem */ private function loadPrivateKeys(): array { $ver = (int) config('pay.wechat_work.msgaudit_public_key_ver', 0); $pemFromPath = ''; $path = (string) config('pay.wechat_work.msgaudit_private_key_path', ''); if ($path !== '' && is_file($path) && is_readable($path)) { $pemFromPath = (string) file_get_contents($path); } $pemInline = (string) config('pay.wechat_work.msgaudit_private_key', ''); $pem = $pemFromPath !== '' ? $pemFromPath : $pemInline; if ($pem === '') { return []; } // 允许配置里是 \n 转义;标准化换行 $pem = str_replace(["\r\n", "\\n"], ["\n", "\n"], $pem); if (strpos($pem, '-----BEGIN') === false) { // 容忍纯 base64:按 64 列切并补头尾 $pem = "-----BEGIN RSA PRIVATE KEY-----\n" . chunk_split(trim($pem), 64, "\n") . "-----END RSA PRIVATE KEY-----\n"; } // 支持未来扩展多版本;目前只有一把,默认索引为配置的 ver(>=1),若为 0 则归到 key=0 return [$ver => $pem]; } /** * 官方 C SDK 头文件的 FFI cdef;与 libWeWorkFinanceSdk_C 头文件完全对应。 * * 官方定义(简化): * struct Slice_t { char* buf; int len; }; * struct MediaData_t { char* outindexbuf; char* data; int data_len; int is_finish; }; */ private function cdef(): string { return <<<'CDEF' typedef struct Slice { char *buf; int len; } Slice_t; typedef struct MediaData { char *outindexbuf; int outindexbuf_len; char *data; int data_len; int is_finish; } MediaData_t; typedef void WeWorkFinanceSdk_t; WeWorkFinanceSdk_t* NewSdk(); int Init(WeWorkFinanceSdk_t* sdk, const char* corpid, const char* secret); void DestroySdk(WeWorkFinanceSdk_t* sdk); int GetChatData(WeWorkFinanceSdk_t* sdk, unsigned long long seq, unsigned int limit, const char* proxy, const char* passwd, int timeout, Slice_t* chatData); int DecryptData(const char* encrypt_key, const char* encrypt_msg, Slice_t* msg); int GetMediaData(WeWorkFinanceSdk_t* sdk, const char* indexbuf, const char* sdkFileid, const char* proxy, const char* passwd, int timeout, MediaData_t* mediaData); Slice_t* NewSlice(); void FreeSlice(Slice_t* slice); char* GetContentFromSlice(Slice_t* slice); int GetSliceLen(Slice_t* slice); MediaData_t* NewMediaData(); void FreeMediaData(MediaData_t* mediaData); char* GetOutIndexBuf(MediaData_t* mediaData); char* GetData(MediaData_t* mediaData); int GetIndexLen(MediaData_t* mediaData); int GetDataLen(MediaData_t* mediaData); int IsMediaDataFinish(MediaData_t* mediaData); CDEF; } }