This commit is contained in:
Your Name
2026-04-21 09:49:44 +08:00
parent c9e0419895
commit c15c348b7a
454 changed files with 5391 additions and 25651 deletions
@@ -0,0 +1,529 @@
<?php
declare(strict_types=1);
namespace app\common\service\wechat;
use FFI;
use think\facade\Log;
/**
* 仅为 IDE/静态分析"看见" FFI::cdef 绑定的 C 函数,不会在运行时使用;
* 运行时 $this->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<int, string> 私钥 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<int, array<string, mixed>>,
* 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_keyRSA-> AES key -> DecryptData 明文
*
* @return array<string, mixed>|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<int, string> 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;
}
}