Files
zyt/server/app/common/service/wechat/WeComFinanceSdkClient.php
T
2026-05-11 17:49:38 +08:00

530 lines
18 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}