530 lines
18 KiB
PHP
530 lines
18 KiB
PHP
<?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_key(RSA)-> 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;
|
||
}
|
||
}
|