first commit
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\wechat;
|
||||
|
||||
use app\common\model\QywxMsgArchive;
|
||||
use app\common\model\QywxMsgArchiveCursor;
|
||||
use app\common\model\QywxMsgArchiveMedia;
|
||||
use app\common\model\QywxMsgSession;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 会话存档拉取与落库
|
||||
*
|
||||
* 工作流程:
|
||||
* 1) 读取游标 seq
|
||||
* 2) 调用 WeComFinanceSdkClient->pullChatData(seq)
|
||||
* 3) 遍历解密后的消息,按 msgtype 归一化字段后 UPSERT qywx_msg_archive
|
||||
* 4) 同步刷新 qywx_msg_session 列表摘要
|
||||
* 5) 媒体消息登记 qywx_msg_archive_media(待下载)
|
||||
* 6) 更新 cursor
|
||||
*
|
||||
* 媒体下载由 downloadPendingMedia() 独立跑,避免拉取慢。
|
||||
*/
|
||||
class QywxMsgArchiveService
|
||||
{
|
||||
private const MEDIA_TYPES = ['image', 'voice', 'video', 'file', 'emotion'];
|
||||
|
||||
/**
|
||||
* 拉一次,直到本批为空或达到 maxBatches
|
||||
*
|
||||
* @return array{
|
||||
* enabled: bool,
|
||||
* batches: int,
|
||||
* pulled: int,
|
||||
* inserted: int,
|
||||
* updated: int,
|
||||
* media_pending: int,
|
||||
* last_seq: int,
|
||||
* errors: string[]
|
||||
* }
|
||||
*/
|
||||
public static function pullLoop(int $maxBatches = 20, string $cursorKey = QywxMsgArchiveCursor::DEFAULT_KEY): array
|
||||
{
|
||||
$result = [
|
||||
'enabled' => true,
|
||||
'batches' => 0,
|
||||
'pulled' => 0,
|
||||
'inserted' => 0,
|
||||
'updated' => 0,
|
||||
'media_pending' => 0,
|
||||
'last_seq' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
|
||||
$sdk = new WeComFinanceSdkClient();
|
||||
if (!$sdk->isAvailable()) {
|
||||
$result['enabled'] = false;
|
||||
$result['errors'][] = $sdk->getLastError();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$seq = self::getCursorSeq($cursorKey);
|
||||
$result['last_seq'] = $seq;
|
||||
|
||||
for ($i = 0; $i < $maxBatches; $i++) {
|
||||
$batch = $sdk->pullChatData($seq);
|
||||
$result['batches']++;
|
||||
|
||||
if (!$batch['ok']) {
|
||||
$result['errors'][] = $batch['error'];
|
||||
self::touchCursor($cursorKey, $seq, 0, $batch['error']);
|
||||
break;
|
||||
}
|
||||
|
||||
$messages = $batch['messages'];
|
||||
$count = count($messages);
|
||||
if ($count === 0) {
|
||||
self::touchCursor($cursorKey, $seq, 0, '');
|
||||
break;
|
||||
}
|
||||
|
||||
[$inserted, $updated, $mediaPending] = self::persistBatch($messages);
|
||||
$result['pulled'] += $count;
|
||||
$result['inserted'] += $inserted;
|
||||
$result['updated'] += $updated;
|
||||
$result['media_pending'] += $mediaPending;
|
||||
|
||||
$seq = (int) $batch['next_seq'];
|
||||
$result['last_seq'] = $seq;
|
||||
self::touchCursor($cursorKey, $seq, $count, '');
|
||||
|
||||
if ($count < $sdk->getLimit()) {
|
||||
// 本批不足 limit,说明暂无更多,结束本轮
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 pending 的媒体文件
|
||||
*
|
||||
* @return array{enabled: bool, ok: int, failed: int, skipped: int, errors: string[]}
|
||||
*/
|
||||
public static function downloadPendingMedia(int $max = 200): array
|
||||
{
|
||||
$result = ['enabled' => true, 'ok' => 0, 'failed' => 0, 'skipped' => 0, 'errors' => []];
|
||||
|
||||
$sdk = new WeComFinanceSdkClient();
|
||||
if (!$sdk->isAvailable()) {
|
||||
$result['enabled'] = false;
|
||||
$result['errors'][] = $sdk->getLastError();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$rows = QywxMsgArchiveMedia::where('status', QywxMsgArchiveMedia::STATUS_PENDING)
|
||||
->where('retry_count', '<', 5)
|
||||
->order('id', 'asc')
|
||||
->limit($max)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sdkFileid = (string) $row['sdkfileid'];
|
||||
if ($sdkFileid === '') {
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
$relPath = self::buildMediaRelPath((string) $row['msgid'], $sdkFileid);
|
||||
$absPath = self::absoluteMediaPath($relPath);
|
||||
|
||||
$ret = $sdk->downloadMedia($sdkFileid, $absPath);
|
||||
$now = time();
|
||||
if ($ret['ok']) {
|
||||
QywxMsgArchiveMedia::where('id', $row['id'])->update([
|
||||
'status' => QywxMsgArchiveMedia::STATUS_SUCCESS,
|
||||
'file_path' => $relPath,
|
||||
'file_size' => $ret['size'],
|
||||
'error' => '',
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$result['ok']++;
|
||||
} else {
|
||||
QywxMsgArchiveMedia::where('id', $row['id'])->update([
|
||||
'retry_count' => (int) $row['retry_count'] + 1,
|
||||
'status' => ((int) $row['retry_count'] + 1) >= 5 ? QywxMsgArchiveMedia::STATUS_FAILED : QywxMsgArchiveMedia::STATUS_PENDING,
|
||||
'error' => mb_substr($ret['error'], 0, 500),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$result['failed']++;
|
||||
$result['errors'][] = sprintf('msgid=%s err=%s', (string) $row['msgid'], $ret['error']);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $messages
|
||||
*
|
||||
* @return array{0:int,1:int,2:int} [inserted, updated, mediaPending]
|
||||
*/
|
||||
private static function persistBatch(array $messages): array
|
||||
{
|
||||
$inserted = 0;
|
||||
$updated = 0;
|
||||
$mediaPending = 0;
|
||||
$now = time();
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$norm = self::normalize($msg);
|
||||
if ($norm === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$isUpdate = false;
|
||||
Db::transaction(function () use ($norm, $now, &$inserted, &$updated, &$mediaPending, &$isUpdate) {
|
||||
$exists = QywxMsgArchive::where('msgid', $norm['msgid'])->findOrEmpty();
|
||||
if ($exists->isEmpty()) {
|
||||
QywxMsgArchive::create(array_merge($norm, [
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]));
|
||||
$inserted++;
|
||||
} else {
|
||||
// recall/switch 会重发相同 msgid,更新 action / content
|
||||
QywxMsgArchive::where('msgid', $norm['msgid'])->update(array_merge($norm, [
|
||||
'update_time' => $now,
|
||||
]));
|
||||
$updated++;
|
||||
$isUpdate = true;
|
||||
}
|
||||
|
||||
if ($norm['media_id'] !== '' && in_array($norm['msgtype'], self::MEDIA_TYPES, true)) {
|
||||
$existsMedia = QywxMsgArchiveMedia::where('msgid', $norm['msgid'])
|
||||
->where('sdkfileid', $norm['media_id'])
|
||||
->findOrEmpty();
|
||||
if ($existsMedia->isEmpty()) {
|
||||
QywxMsgArchiveMedia::create([
|
||||
'msgid' => $norm['msgid'],
|
||||
'sdkfileid' => $norm['media_id'],
|
||||
'status' => QywxMsgArchiveMedia::STATUS_PENDING,
|
||||
'md5sum' => $norm['md5sum'],
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$mediaPending++;
|
||||
}
|
||||
}
|
||||
|
||||
self::upsertSession($norm, $now);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
Log::error(sprintf(
|
||||
'会话存档-消息落库异常 msgid=%s err=%s',
|
||||
(string) ($norm['msgid'] ?? '-'),
|
||||
$e->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return [$inserted, $updated, $mediaPending];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把解密后的原始消息归一化为数据库字段
|
||||
*
|
||||
* @param array<string, mixed> $msg
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function normalize(array $msg): ?array
|
||||
{
|
||||
$msgid = (string) ($msg['msgid'] ?? $msg['__msgid_sdk'] ?? '');
|
||||
if ($msgid === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$action = (string) ($msg['action'] ?? 'send'); // send / recall / switch
|
||||
$msgtype = (string) ($msg['msgtype'] ?? '');
|
||||
$from = (string) ($msg['from'] ?? '');
|
||||
$roomid = (string) ($msg['roomid'] ?? '');
|
||||
$tolist = isset($msg['tolist']) && is_array($msg['tolist']) ? $msg['tolist'] : [];
|
||||
$sendTime = isset($msg['msgtime']) ? (int) (((int) $msg['msgtime']) / 1000) : 0;
|
||||
if ($sendTime === 0 && isset($msg['msgtime'])) {
|
||||
// 兼容已经是秒的情况
|
||||
$sendTime = (int) $msg['msgtime'];
|
||||
}
|
||||
|
||||
$content = '';
|
||||
$mediaId = '';
|
||||
$md5sum = '';
|
||||
$fileName = '';
|
||||
$fileSize = 0;
|
||||
$fileExt = '';
|
||||
$playLength = 0;
|
||||
|
||||
switch ($msgtype) {
|
||||
case 'text':
|
||||
$content = (string) ($msg['text']['content'] ?? '');
|
||||
break;
|
||||
case 'markdown':
|
||||
$content = (string) ($msg['markdown']['content'] ?? '');
|
||||
break;
|
||||
case 'image':
|
||||
$mediaId = (string) ($msg['image']['sdkfileid'] ?? '');
|
||||
$md5sum = (string) ($msg['image']['md5sum'] ?? '');
|
||||
$fileSize = (int) ($msg['image']['filesize'] ?? 0);
|
||||
$content = '[图片]';
|
||||
break;
|
||||
case 'voice':
|
||||
$mediaId = (string) ($msg['voice']['sdkfileid'] ?? '');
|
||||
$md5sum = (string) ($msg['voice']['md5sum'] ?? '');
|
||||
$fileSize = (int) ($msg['voice']['voice_size'] ?? 0);
|
||||
$playLength = (int) ($msg['voice']['play_length'] ?? 0);
|
||||
$content = sprintf('[语音 %ds]', $playLength);
|
||||
break;
|
||||
case 'video':
|
||||
$mediaId = (string) ($msg['video']['sdkfileid'] ?? '');
|
||||
$md5sum = (string) ($msg['video']['md5sum'] ?? '');
|
||||
$fileSize = (int) ($msg['video']['filesize'] ?? 0);
|
||||
$playLength = (int) ($msg['video']['play_length'] ?? 0);
|
||||
$content = '[视频]';
|
||||
break;
|
||||
case 'emotion':
|
||||
$mediaId = (string) ($msg['emotion']['sdkfileid'] ?? '');
|
||||
$md5sum = (string) ($msg['emotion']['md5sum'] ?? '');
|
||||
$fileSize = (int) ($msg['emotion']['imagesize'] ?? 0);
|
||||
$content = '[表情]';
|
||||
break;
|
||||
case 'file':
|
||||
$mediaId = (string) ($msg['file']['sdkfileid'] ?? '');
|
||||
$md5sum = (string) ($msg['file']['md5sum'] ?? '');
|
||||
$fileName = (string) ($msg['file']['filename'] ?? '');
|
||||
$fileExt = (string) ($msg['file']['fileext'] ?? '');
|
||||
$fileSize = (int) ($msg['file']['filesize'] ?? 0);
|
||||
$content = '[文件] ' . $fileName;
|
||||
break;
|
||||
case 'link':
|
||||
$content = (string) ($msg['link']['title'] ?? '[链接]');
|
||||
break;
|
||||
case 'weapp':
|
||||
$content = '[小程序] ' . (string) ($msg['weapp']['title'] ?? '');
|
||||
break;
|
||||
case 'chatrecord':
|
||||
$content = '[聊天记录]';
|
||||
break;
|
||||
case 'location':
|
||||
$content = '[位置] ' . (string) ($msg['location']['address'] ?? '');
|
||||
break;
|
||||
case 'card':
|
||||
$content = '[名片]';
|
||||
break;
|
||||
case 'revoke':
|
||||
$action = 'recall';
|
||||
$content = '[撤回] ' . (string) ($msg['revoke']['pre_msgid'] ?? '');
|
||||
break;
|
||||
case 'mixed':
|
||||
$content = self::summarizeMixed($msg['mixed'] ?? null);
|
||||
break;
|
||||
case 'meeting':
|
||||
$content = '[会议] ' . (string) ($msg['meeting']['topic'] ?? '');
|
||||
break;
|
||||
case 'docmsg':
|
||||
$content = '[文档] ' . (string) ($msg['docmsg']['title'] ?? '');
|
||||
break;
|
||||
default:
|
||||
$content = '[' . $msgtype . ']';
|
||||
}
|
||||
|
||||
return [
|
||||
'msgid' => $msgid,
|
||||
'seq' => (int) ($msg['__seq'] ?? 0),
|
||||
'action' => $action,
|
||||
'from_user' => $from,
|
||||
'from_type' => 0, // 外层 session upsert 时再判定
|
||||
'to_list' => $tolist,
|
||||
'roomid' => $roomid,
|
||||
'msgtype' => $msgtype,
|
||||
'content' => mb_substr($content, 0, 16000),
|
||||
'media_id' => $mediaId,
|
||||
'md5sum' => $md5sum,
|
||||
'file_name' => mb_substr($fileName, 0, 500),
|
||||
'file_size' => $fileSize,
|
||||
'file_ext' => $fileExt,
|
||||
'play_length' => $playLength,
|
||||
'raw' => json_encode($msg, JSON_UNESCAPED_UNICODE),
|
||||
'send_time' => $sendTime,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $mixed
|
||||
*/
|
||||
private static function summarizeMixed($mixed): string
|
||||
{
|
||||
if (!is_array($mixed) || !isset($mixed['item']) || !is_array($mixed['item'])) {
|
||||
return '[混合消息]';
|
||||
}
|
||||
$parts = [];
|
||||
foreach ($mixed['item'] as $el) {
|
||||
if (!is_array($el)) {
|
||||
continue;
|
||||
}
|
||||
$t = (string) ($el['type'] ?? '');
|
||||
if ($t === 'text') {
|
||||
$parts[] = (string) ($el['content'] ?? '');
|
||||
} else {
|
||||
$parts[] = '[' . $t . ']';
|
||||
}
|
||||
if (count($parts) >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
private static function upsertSession(array $norm, int $now): void
|
||||
{
|
||||
// 外部联系人 id 的企微前缀通常是 "wm" 或 "wo";员工 userid 无此前缀。
|
||||
// 单聊时 tolist 有 1 个对端;群聊走 roomid。
|
||||
$tolist = $norm['to_list'] ?? [];
|
||||
if ($norm['roomid'] !== '') {
|
||||
$sessionType = 2;
|
||||
$staff = self::isStaffUserid($norm['from_user']) ? $norm['from_user'] : '';
|
||||
if ($staff === '') {
|
||||
foreach ($tolist as $to) {
|
||||
if (self::isStaffUserid($to)) {
|
||||
$staff = (string) $to;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$external = '';
|
||||
} else {
|
||||
$sessionType = 1;
|
||||
if (self::isStaffUserid($norm['from_user'])) {
|
||||
$staff = $norm['from_user'];
|
||||
$external = (string) ($tolist[0] ?? '');
|
||||
} else {
|
||||
$external = $norm['from_user'];
|
||||
$staff = (string) ($tolist[0] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
if ($staff === '' && $norm['roomid'] === '') {
|
||||
// 兜底:两端都辨不出,跳过 session 更新
|
||||
return;
|
||||
}
|
||||
|
||||
$where = [
|
||||
'staff_userid' => $staff,
|
||||
'external_userid' => $external,
|
||||
'roomid' => $norm['roomid'],
|
||||
];
|
||||
|
||||
$summary = mb_substr((string) $norm['content'], 0, 500);
|
||||
$data = [
|
||||
'session_type' => $sessionType,
|
||||
'last_msg_id' => $norm['msgid'],
|
||||
'last_msg_seq' => $norm['seq'],
|
||||
'last_msg_time' => $norm['send_time'],
|
||||
'last_msg_type' => $norm['msgtype'],
|
||||
'last_msg_summary' => $summary,
|
||||
'update_time' => $now,
|
||||
];
|
||||
|
||||
$existing = QywxMsgSession::where($where)->findOrEmpty();
|
||||
if ($existing->isEmpty()) {
|
||||
QywxMsgSession::create(array_merge($where, $data, [
|
||||
'unread_staff' => self::isStaffUserid($norm['from_user']) ? 0 : 1,
|
||||
'create_time' => $now,
|
||||
]));
|
||||
} else {
|
||||
// 仅当本条更新,才覆盖最近消息字段
|
||||
if ((int) $existing['last_msg_time'] <= (int) $norm['send_time']) {
|
||||
if (!self::isStaffUserid($norm['from_user'])) {
|
||||
$data['unread_staff'] = (int) $existing['unread_staff'] + 1;
|
||||
}
|
||||
QywxMsgSession::where('id', $existing['id'])->update($data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 粗判 userid 是员工还是外部联系人:企微外部联系人 id 以 "wm"/"wo" 开头且长度较长;
|
||||
* 员工 userid 是管理员配置的,通常是字母/数字无这两个前缀。
|
||||
*/
|
||||
private static function isStaffUserid(string $id): bool
|
||||
{
|
||||
if ($id === '') {
|
||||
return false;
|
||||
}
|
||||
$lower = strtolower($id);
|
||||
if (strpos($lower, 'wm') === 0 || strpos($lower, 'wo') === 0) {
|
||||
// 仍需长度判断:员工 userid 以 wm 开头的极少见;外部联系人通常 20+ 字符
|
||||
return strlen($id) < 20;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function buildMediaRelPath(string $msgid, string $sdkFileid): string
|
||||
{
|
||||
$hash = substr(md5($msgid . ':' . $sdkFileid), 0, 16);
|
||||
|
||||
return 'qywx_archive/' . date('Ymd') . '/' . $hash;
|
||||
}
|
||||
|
||||
private static function absoluteMediaPath(string $relPath): string
|
||||
{
|
||||
$base = rtrim((string) config('filesystem.disks.public.root', public_path() . 'storage'), DIRECTORY_SEPARATOR);
|
||||
|
||||
return $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relPath);
|
||||
}
|
||||
|
||||
private static function getCursorSeq(string $cursorKey): int
|
||||
{
|
||||
$row = QywxMsgArchiveCursor::where('cursor_key', $cursorKey)->findOrEmpty();
|
||||
|
||||
return $row->isEmpty() ? 0 : (int) $row['seq'];
|
||||
}
|
||||
|
||||
private static function touchCursor(string $cursorKey, int $seq, int $pullCount, string $error): void
|
||||
{
|
||||
$now = time();
|
||||
$row = QywxMsgArchiveCursor::where('cursor_key', $cursorKey)->findOrEmpty();
|
||||
if ($row->isEmpty()) {
|
||||
QywxMsgArchiveCursor::create([
|
||||
'cursor_key' => $cursorKey,
|
||||
'seq' => $seq,
|
||||
'last_pull_time' => $now,
|
||||
'last_pull_count' => $pullCount,
|
||||
'last_error' => mb_substr($error, 0, 500),
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
} else {
|
||||
QywxMsgArchiveCursor::where('id', $row['id'])->update([
|
||||
'seq' => $seq,
|
||||
'last_pull_time' => $now,
|
||||
'last_pull_count' => $pullCount,
|
||||
'last_error' => mb_substr($error, 0, 500),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
namespace app\common\service\wechat;
|
||||
|
||||
use app\common\enum\PayEnum;
|
||||
use app\common\enum\user\UserTerminalEnum;
|
||||
use app\common\model\pay\PayConfig;
|
||||
use app\common\service\ConfigService;
|
||||
|
||||
/**
|
||||
* 微信配置类
|
||||
* Class WeChatConfigService
|
||||
* @package app\common\service
|
||||
*/
|
||||
class WeChatConfigService
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 获取小程序配置
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/9/6 19:49
|
||||
*/
|
||||
public static function getMnpConfig()
|
||||
{
|
||||
return [
|
||||
'app_id' => ConfigService::get('mnp_setting', 'app_id'),
|
||||
'secret' => ConfigService::get('mnp_setting', 'app_secret'),
|
||||
'response_type' => 'array',
|
||||
'log' => [
|
||||
'level' => 'debug',
|
||||
'file' => app()->getRootPath() . 'runtime/wechat/' . date('Ym') . '/' . date('d') . '.log'
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取微信公众号配置
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/9/6 19:49
|
||||
*/
|
||||
public static function getOaConfig()
|
||||
{
|
||||
return [
|
||||
'app_id' => ConfigService::get('oa_setting', 'app_id'),
|
||||
'secret' => ConfigService::get('oa_setting', 'app_secret'),
|
||||
'token' => ConfigService::get('oa_setting', 'token'),
|
||||
'response_type' => 'array',
|
||||
'log' => [
|
||||
'level' => 'debug',
|
||||
'file' => app()->getRootPath() . 'runtime/wechat/' . date('Ym') . '/' . date('d') . '.log'
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取微信开放平台配置
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2022/10/20 15:51
|
||||
*/
|
||||
public static function getOpConfig()
|
||||
{
|
||||
return [
|
||||
'app_id' => ConfigService::get('open_platform', 'app_id'),
|
||||
'secret' => ConfigService::get('open_platform', 'app_secret'),
|
||||
'response_type' => 'array',
|
||||
'log' => [
|
||||
'level' => 'debug',
|
||||
'file' => app()->getRootPath() . 'runtime/wechat/' . date('Ym') . '/' . date('d') . '.log'
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 根据终端获取支付配置
|
||||
* @param $terminal
|
||||
* @return array
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 15:45
|
||||
*/
|
||||
public static function getPayConfigByTerminal($terminal)
|
||||
{
|
||||
switch ($terminal) {
|
||||
case UserTerminalEnum::WECHAT_MMP:
|
||||
$notifyUrl = (string)url('pay/notifyMnp', [], false, true);
|
||||
break;
|
||||
case UserTerminalEnum::WECHAT_OA:
|
||||
case UserTerminalEnum::PC:
|
||||
case UserTerminalEnum::H5:
|
||||
$notifyUrl = (string)url('pay/notifyOa', [], false, true);
|
||||
break;
|
||||
case UserTerminalEnum::ANDROID:
|
||||
case UserTerminalEnum::IOS:
|
||||
$notifyUrl = (string)url('pay/notifyApp', [], false, true);
|
||||
break;
|
||||
}
|
||||
|
||||
$pay = PayConfig::where(['pay_way' => PayEnum::WECHAT_PAY])->findOrEmpty()->toArray();
|
||||
//判断是否已经存在证书文件夹,不存在则新建
|
||||
if (!file_exists(app()->getRootPath() . 'runtime/cert')) {
|
||||
mkdir(app()->getRootPath() . 'runtime/cert', 0775, true);
|
||||
}
|
||||
//写入文件
|
||||
$apiclientCert = $pay['config']['apiclient_cert'] ?? '';
|
||||
$apiclientKey = $pay['config']['apiclient_key'] ?? '';
|
||||
|
||||
$certPath = app()->getRootPath() . 'runtime/cert/' . md5($apiclientCert) . '.pem';
|
||||
$keyPath = app()->getRootPath() . 'runtime/cert/' . md5($apiclientKey) . '.pem';
|
||||
|
||||
if (!empty($apiclientCert) && !file_exists($certPath)) {
|
||||
static::setCert($certPath, trim($apiclientCert));
|
||||
}
|
||||
if (!empty($apiclientKey) && !file_exists($keyPath)) {
|
||||
static::setCert($keyPath, trim($apiclientKey));
|
||||
}
|
||||
|
||||
return [
|
||||
// 商户号
|
||||
'mch_id' => $pay['config']['mch_id'] ?? '',
|
||||
// 商户证书
|
||||
'private_key' => $keyPath,
|
||||
'certificate' => $certPath,
|
||||
// v3 API 秘钥
|
||||
'secret_key' => $pay['config']['pay_sign_key'] ?? '',
|
||||
'notify_url' => $notifyUrl,
|
||||
'http' => [
|
||||
'throw' => true, // 状态码非 200、300 时是否抛出异常,默认为开启
|
||||
'timeout' => 5.0,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 临时写入证书
|
||||
* @param $path
|
||||
* @param $cert
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 15:48
|
||||
*/
|
||||
public static function setCert($path, $cert)
|
||||
{
|
||||
$fopenPath = fopen($path, 'w');
|
||||
fwrite($fopenPath, $cert);
|
||||
fclose($fopenPath);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
namespace app\common\service\wechat;
|
||||
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\Exception;
|
||||
use EasyWeChat\MiniApp\Application;
|
||||
|
||||
|
||||
/**
|
||||
* 微信功能类
|
||||
* Class WeChatMnpService
|
||||
* @package app\common\service
|
||||
*/
|
||||
class WeChatMnpService
|
||||
{
|
||||
|
||||
protected $app;
|
||||
|
||||
protected $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = $this->getConfig();
|
||||
$this->app = new Application($this->config);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 配置
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 12:03
|
||||
*/
|
||||
protected function getConfig()
|
||||
{
|
||||
$config = WeChatConfigService::getMnpConfig();
|
||||
if (empty($config['app_id']) || empty($config['secret'])) {
|
||||
throw new \Exception('请先设置小程序配置');
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 小程序-根据code获取微信信息
|
||||
* @param string $code
|
||||
* @return array
|
||||
* @throws Exception
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 11:03
|
||||
*/
|
||||
public function getMnpResByCode(string $code)
|
||||
{
|
||||
$utils = $this->app->getUtils();
|
||||
$response = $utils->codeToSession($code);
|
||||
|
||||
if (!isset($response['openid']) || empty($response['openid'])) {
|
||||
throw new Exception('获取openID失败');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取手机号
|
||||
* @param string $code
|
||||
* @return \EasyWeChat\Kernel\HttpClient\Response|\Symfony\Contracts\HttpClient\ResponseInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 11:46
|
||||
*/
|
||||
public function getUserPhoneNumber(string $code)
|
||||
{
|
||||
return $this->app->getClient()->postJson('wxa/business/getuserphonenumber', [
|
||||
'code' => $code,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
namespace app\common\service\wechat;
|
||||
|
||||
|
||||
use EasyWeChat\Kernel\Exceptions\Exception;
|
||||
use EasyWeChat\OfficialAccount\Application;
|
||||
|
||||
|
||||
/**
|
||||
* 公众号相关
|
||||
* Class WeChatOaService
|
||||
* @package app\common\service\wechat
|
||||
*/
|
||||
class WeChatOaService
|
||||
{
|
||||
|
||||
protected $app;
|
||||
|
||||
protected $config;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = $this->getConfig();
|
||||
$this->app = new Application($this->config);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes easywechat服务端
|
||||
* @return \EasyWeChat\Kernel\Contracts\Server|\EasyWeChat\OfficialAccount\Server
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @throws \ReflectionException
|
||||
* @throws \Throwable
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 14:22
|
||||
*/
|
||||
public function getServer()
|
||||
{
|
||||
return $this->app->getServer();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 配置
|
||||
* @return array
|
||||
* @throws Exception
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 12:03
|
||||
*/
|
||||
protected function getConfig()
|
||||
{
|
||||
$config = WeChatConfigService::getOaConfig();
|
||||
if (empty($config['app_id']) || empty($config['secret'])) {
|
||||
throw new Exception('请先设置公众号配置');
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 公众号-根据code获取微信信息
|
||||
* @param string $code
|
||||
* @return mixed
|
||||
* @throws Exception
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 11:04
|
||||
*/
|
||||
public function getOaResByCode(string $code)
|
||||
{
|
||||
$response = $this->app->getOAuth()
|
||||
->scopes(['snsapi_userinfo'])
|
||||
->userFromCode($code)
|
||||
->getRaw();
|
||||
|
||||
if (!isset($response['openid']) || empty($response['openid'])) {
|
||||
throw new Exception('获取openID失败');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 公众号跳转url
|
||||
* @param string $url
|
||||
* @return mixed
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 10:35
|
||||
*/
|
||||
public function getCodeUrl(string $url)
|
||||
{
|
||||
return $this->app->getOAuth()
|
||||
->scopes(['snsapi_userinfo'])
|
||||
->redirect($url);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 创建公众号菜单
|
||||
* @param array $buttons
|
||||
* @param array $matchRule
|
||||
* @return \EasyWeChat\Kernel\HttpClient\Response|\Symfony\Contracts\HttpClient\ResponseInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @author 段誉
|
||||
* @date 2023/2/27 12:07
|
||||
*/
|
||||
public function createMenu(array $buttons, array $matchRule = [])
|
||||
{
|
||||
if (!empty($matchRule)) {
|
||||
return $this->app->getClient()->postJson('cgi-bin/menu/addconditional', [
|
||||
'button' => $buttons,
|
||||
'matchrule' => $matchRule,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->app->getClient()->postJson('cgi-bin/menu/create', ['button' => $buttons]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取jssdkConfig
|
||||
* @param $url
|
||||
* @param $jsApiList
|
||||
* @param array $openTagList
|
||||
* @param false $debug
|
||||
* @return mixed[]
|
||||
* @throws \EasyWeChat\Kernel\Exceptions\HttpException
|
||||
* @throws \Psr\SimpleCache\InvalidArgumentException
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
|
||||
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
|
||||
* @author 段誉
|
||||
* @date 2023/3/1 11:46
|
||||
*/
|
||||
public function getJsConfig($url, $jsApiList, $openTagList = [], $debug = false)
|
||||
{
|
||||
return $this->app->getUtils()->buildJsSdkConfig($url, $jsApiList, $openTagList, $debug);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
namespace app\common\service\wechat;
|
||||
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use WpOrg\Requests\Requests;
|
||||
|
||||
|
||||
/**
|
||||
* 自定义微信请求
|
||||
* Class WeChatRequestService
|
||||
* @package app\common\service\wechat
|
||||
*/
|
||||
class WeChatRequestService extends BaseLogic
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 获取网站扫码登录地址
|
||||
* @param $appId
|
||||
* @param $redirectUri
|
||||
* @param $state
|
||||
* @return string
|
||||
* @author 段誉
|
||||
* @date 2022/10/20 18:20
|
||||
*/
|
||||
public static function getScanCodeUrl($appId, $redirectUri, $state)
|
||||
{
|
||||
$url = 'https://open.weixin.qq.com/connect/qrconnect?';
|
||||
$url .= 'appid=' . $appId . '&redirect_uri=' . $redirectUri . '&response_type=code&scope=snsapi_login';
|
||||
$url .= '&state=' . $state . '#wechat_redirect';
|
||||
return $url;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 通过code获取用户信息(access_token,openid,unionid等)
|
||||
* @param $code
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2022/10/21 10:16
|
||||
*/
|
||||
public static function getUserAuthByCode($code)
|
||||
{
|
||||
$config = WeChatConfigService::getOpConfig();
|
||||
$url = 'https://api.weixin.qq.com/sns/oauth2/access_token';
|
||||
$url .= '?appid=' . $config['app_id'] . '&secret=' . $config['secret'] . '&code=' . $code;
|
||||
$url .= '&grant_type=authorization_code';
|
||||
$requests = Requests::get($url);
|
||||
return json_decode($requests->body, true);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 通过授权信息获取用户信息
|
||||
* @param $accessToken
|
||||
* @param $openId
|
||||
* @return mixed
|
||||
* @author 段誉
|
||||
* @date 2022/10/21 10:21
|
||||
*/
|
||||
public static function getUserInfoByAuth($accessToken, $openId)
|
||||
{
|
||||
$url = 'https://api.weixin.qq.com/sns/userinfo';
|
||||
$url .= '?access_token=' . $accessToken . '&openid=' . $openId;
|
||||
$response = Requests::get($url);
|
||||
return json_decode($response->body, true);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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_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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\wechat;
|
||||
|
||||
use think\facade\Cache;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 企业微信自建应用:向成员发送应用文本消息
|
||||
*
|
||||
* 依赖配置(与登录/绑定可共用 corp_id;发消息必须用「同一自建应用」的 AgentId + Secret):
|
||||
* - WECHAT_WORK_CORP_ID 或 work_wechat.corp_id
|
||||
* - WECHAT_WORK_AGENT_ID 或 work_wechat.agent_id
|
||||
* - WECHAT_WORK_AGENT_SECRET 或 work_wechat.agent_secret(推荐;勿与仅用于网页授权的 secret 混用)
|
||||
* - 若未配 agent_secret,会回退 work_wechat.secret(若仍无法发消息,请在管理后台复制该应用的 Secret 填到 agent_secret)
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/90236
|
||||
*/
|
||||
class WechatWorkAppMessageService
|
||||
{
|
||||
/**
|
||||
* @return array{0: string, 1: int, 2: string} [corpId, agentId, secret]
|
||||
*/
|
||||
private static function resolveMessagingCredentials(): array
|
||||
{
|
||||
$corpId = (string) env('WECHAT_WORK_CORP_ID', '');
|
||||
if ($corpId === '') {
|
||||
$corpId = (string) env('work_wechat.corp_id', '');
|
||||
}
|
||||
|
||||
$agentIdStr = (string) env('WECHAT_WORK_AGENT_ID', '');
|
||||
if ($agentIdStr === '') {
|
||||
$agentIdStr = (string) env('work_wechat.agent_id', '');
|
||||
}
|
||||
$agentId = (int) $agentIdStr;
|
||||
|
||||
$secret = (string) env('WECHAT_WORK_AGENT_SECRET', '');
|
||||
if ($secret === '') {
|
||||
$secret = (string) env('work_wechat.agent_secret', '');
|
||||
}
|
||||
if ($secret === '') {
|
||||
$secret = (string) env('work_wechat.secret', '');
|
||||
}
|
||||
|
||||
return [$corpId, $agentId, $secret];
|
||||
}
|
||||
|
||||
private static function getAccessToken(string $corpId, string $secret): ?string
|
||||
{
|
||||
if ($corpId === '' || $secret === '') {
|
||||
return null;
|
||||
}
|
||||
$cacheKey = 'work_wechat_app_msg_token_' . md5($corpId . $secret);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached) {
|
||||
return (string) $cached;
|
||||
}
|
||||
|
||||
$url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={$corpId}&corpsecret={$secret}";
|
||||
$res = self::httpGetJson($url);
|
||||
if (!$res || (int) ($res['errcode'] ?? -1) !== 0) {
|
||||
Log::error('企业微信应用 access_token 失败: ' . ($res['errmsg'] ?? '无响应'));
|
||||
|
||||
return null;
|
||||
}
|
||||
$token = (string) ($res['access_token'] ?? '');
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
$ttl = (int) ($res['expires_in'] ?? 7200) - 200;
|
||||
if ($ttl > 0) {
|
||||
Cache::set($cacheKey, $token, $ttl);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private static function httpGetJson(string $url): ?array
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
if ($result === false || $result === '') {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($result, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private static function httpPostJson(string $url, array $body): ?array
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json; charset=utf-8']);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
if ($result === false || $result === '') {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($result, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向指定成员发送文本消息(userid 为企业微信成员账号,与后台 work_wechat_userid 一致)
|
||||
*
|
||||
* @return array{ok: bool, message?: string, errcode?: int}
|
||||
*/
|
||||
public static function sendTextToUser(string $toUser, string $content): array
|
||||
{
|
||||
$toUser = trim($toUser);
|
||||
$content = trim($content);
|
||||
if ($toUser === '' || $content === '') {
|
||||
return ['ok' => false, 'message' => '接收人或消息内容为空'];
|
||||
}
|
||||
|
||||
[$corpId, $agentId, $secret] = self::resolveMessagingCredentials();
|
||||
|
||||
if ($corpId === '' || $agentId <= 0 || $secret === '') {
|
||||
Log::warning('企业微信应用消息未配置完整(corp_id/agent_id/secret),跳过发消息');
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => '服务端未配置企业微信应用消息(需 corp_id、agent_id 及该应用的 Secret,建议配置 WECHAT_WORK_AGENT_SECRET)',
|
||||
];
|
||||
}
|
||||
|
||||
$token = self::getAccessToken($corpId, $secret);
|
||||
if (!$token) {
|
||||
return ['ok' => false, 'message' => '获取企业微信 access_token 失败,请查看服务端日志'];
|
||||
}
|
||||
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=' . urlencode($token);
|
||||
$body = [
|
||||
'touser' => $toUser,
|
||||
'msgtype' => 'text',
|
||||
'agentid' => $agentId,
|
||||
'text' => ['content' => $content],
|
||||
'safe' => 0,
|
||||
];
|
||||
|
||||
$res = self::httpPostJson($url, $body);
|
||||
if (!$res) {
|
||||
Log::error('企业微信发消息无响应');
|
||||
|
||||
return ['ok' => false, 'message' => '企业微信接口无响应'];
|
||||
}
|
||||
$err = (int) ($res['errcode'] ?? -1);
|
||||
if ($err !== 0) {
|
||||
$errmsg = (string) ($res['errmsg'] ?? '');
|
||||
Log::error('企业微信发消息失败: ' . $errmsg . ' errcode=' . $err);
|
||||
|
||||
$hint = '';
|
||||
if ($err === 81013 || $err === 60111) {
|
||||
$hint = '请核对开方人绑定的 userid 与企业微信成员账号一致,且成员已安装/可见该应用。';
|
||||
} elseif ($err === 60020) {
|
||||
$hint = '请将服务器出口 IP 加入企业微信应用可信 IP。';
|
||||
} elseif ($err === 60011) {
|
||||
$hint = '请检查应用是否具备发消息能力及可见范围是否包含该成员。';
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => '企业微信返回:' . $errmsg . '(errcode=' . $err . ')' . ($hint !== '' ? ' ' . $hint : ''),
|
||||
'errcode' => $err,
|
||||
];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\wechat;
|
||||
|
||||
use think\facade\Cache;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 企业微信对外收款服务
|
||||
* 参考 https://developer.work.weixin.qq.com/document/path/93727
|
||||
*/
|
||||
class WechatWorkExternalPayService
|
||||
{
|
||||
private string $corpId;
|
||||
private string $secret;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = config('pay.wechat_work', []);
|
||||
$this->corpId = (string)($config['corp_id'] ?? '');
|
||||
$this->secret = (string)($config['external_pay_secret'] ?? $config['secret'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 access_token(对外收款应用)
|
||||
*/
|
||||
public function getAccessToken(): ?string
|
||||
{
|
||||
if (empty($this->corpId) || empty($this->secret)) {
|
||||
Log::error('企业微信对外收款:corp_id 或 external_pay_secret 未配置');
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = 'work_wechat_external_pay_token_' . md5($this->corpId . $this->secret);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if ($cached) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={$this->corpId}&corpsecret={$this->secret}";
|
||||
$response = $this->httpGet($url);
|
||||
|
||||
if (!$response || ($response['errcode'] ?? -1) != 0) {
|
||||
Log::error('企业微信对外收款获取access_token失败: ' . json_encode($response));
|
||||
return null;
|
||||
}
|
||||
|
||||
$accessToken = $response['access_token'];
|
||||
Cache::set($cacheKey, $accessToken, ($response['expires_in'] ?? 7200) - 200);
|
||||
return $accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对外收款记录
|
||||
* @param int $beginTime 开始时间戳(秒)
|
||||
* @param int $endTime 结束时间戳(秒)
|
||||
* @param string $payeeUserid 收款人userid,空则查全部
|
||||
* @param string $cursor 分页游标
|
||||
* @param int $limit 最大记录数,最大1000
|
||||
* @return array
|
||||
*/
|
||||
public function getBillList(int $beginTime, int $endTime, string $payeeUserid = '', string $cursor = '', int $limit = 100): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (!$accessToken) {
|
||||
return ['errcode' => -1, 'errmsg' => '获取access_token失败', 'bill_list' => []];
|
||||
}
|
||||
|
||||
$url = "https://qyapi.weixin.qq.com/cgi-bin/externalpay/get_bill_list?access_token={$accessToken}";
|
||||
$data = [
|
||||
'begin_time' => $beginTime,
|
||||
'end_time' => $endTime,
|
||||
'limit' => $limit,
|
||||
];
|
||||
if ($payeeUserid) {
|
||||
$data['payee_userid'] = $payeeUserid;
|
||||
}
|
||||
if ($cursor) {
|
||||
$data['cursor'] = $cursor;
|
||||
}
|
||||
|
||||
$response = $this->httpPost($url, $data);
|
||||
if (!$response) {
|
||||
return ['errcode' => -1, 'errmsg' => '请求失败', 'bill_list' => []];
|
||||
}
|
||||
|
||||
$billList = $response['bill_list'] ?? [];
|
||||
return [
|
||||
'errcode' => $response['errcode'] ?? 0,
|
||||
'errmsg' => $response['errmsg'] ?? '',
|
||||
'bill_list' => is_array($billList) ? $billList : [],
|
||||
'next_cursor' => $response['next_cursor'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
private function httpGet(string $url)
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return $result ? json_decode($result, true) : null;
|
||||
}
|
||||
|
||||
private function httpPost(string $url, array $data)
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return $result ? json_decode($result, true) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\wechat;
|
||||
|
||||
use think\facade\Cache;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 企业微信服务类
|
||||
* @see https://developer.work.weixin.qq.com/document/
|
||||
*/
|
||||
class WechatWorkService
|
||||
{
|
||||
private $corpId;
|
||||
private $secret;
|
||||
/** 实际选用的 secret 语义标签:customer_contact / external_pay / unknown,便于排错 */
|
||||
private string $secretLabel = 'unknown';
|
||||
private $accessToken;
|
||||
|
||||
/**
|
||||
* @param string|null $secretType 'customer_contact'(客户联系 API)或 'external_pay'(对外收款 API);
|
||||
* 缺省按调用方法的语义自动选择,未指定时优先使用客户联系 Secret,回退到对外收款。
|
||||
*/
|
||||
public function __construct(?string $secretType = null)
|
||||
{
|
||||
$this->corpId = config('pay.wechat_work.corp_id');
|
||||
|
||||
$customerSecret = (string) config('pay.wechat_work.customer_contact_secret', '');
|
||||
$payContactSecret = (string) config('pay.wechat_work.external_pay_secret', '');
|
||||
|
||||
if ($secretType === 'external_pay') {
|
||||
$this->secret = $payContactSecret;
|
||||
$this->secretLabel = 'external_pay';
|
||||
} elseif ($secretType === 'customer_contact') {
|
||||
if ($customerSecret !== '') {
|
||||
$this->secret = $customerSecret;
|
||||
$this->secretLabel = 'customer_contact';
|
||||
} else {
|
||||
$this->secret = $payContactSecret;
|
||||
$this->secretLabel = 'external_pay(fallback)';
|
||||
}
|
||||
} else {
|
||||
// 历史调用:本类绝大多数方法走的是「客户联系」API,因此默认优先取客户联系 Secret,
|
||||
// 未配置时回退到对外收款 Secret,保持向后兼容(同一应用同时具备两类权限的旧部署可继续工作)。
|
||||
if ($customerSecret !== '') {
|
||||
$this->secret = $customerSecret;
|
||||
$this->secretLabel = 'customer_contact';
|
||||
} else {
|
||||
$this->secret = $payContactSecret;
|
||||
$this->secretLabel = 'external_pay(fallback)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前选用的 secret 语义标签 */
|
||||
public function getSecretLabel(): string
|
||||
{
|
||||
return $this->secretLabel;
|
||||
}
|
||||
|
||||
/** 对 secret 做脱敏展示,用于错误日志定位 */
|
||||
private function maskSecret(): string
|
||||
{
|
||||
$s = (string) $this->secret;
|
||||
if ($s === '') {
|
||||
return '(empty)';
|
||||
}
|
||||
$len = strlen($s);
|
||||
if ($len <= 8) {
|
||||
return str_repeat('*', $len);
|
||||
}
|
||||
|
||||
return substr($s, 0, 4) . str_repeat('*', $len - 8) . substr($s, -4) . '(len=' . $len . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取access_token
|
||||
* @see https://developer.work.weixin.qq.com/document/path/91039
|
||||
*/
|
||||
private function getAccessToken(): string
|
||||
{
|
||||
if ($this->accessToken) {
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
// 缓存 key 必须按 corp+secret 分桶,否则多应用(customer_contact / external_pay)之间会相互覆盖,
|
||||
// 读到别家应用的 token 去调本家接口 → 偶发 40001 / 40014 / 60011。
|
||||
$cacheKey = 'qywx_access_token:' . md5($this->corpId . '|' . $this->secret);
|
||||
$token = Cache::get($cacheKey);
|
||||
if ($token) {
|
||||
$this->accessToken = $token;
|
||||
return $token;
|
||||
}
|
||||
|
||||
// 请求新token
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken';
|
||||
$params = [
|
||||
'corpid' => $this->corpId,
|
||||
'corpsecret' => $this->secret,
|
||||
];
|
||||
|
||||
$response = $this->httpGet($url, $params);
|
||||
if (isset($response['access_token'])) {
|
||||
$token = $response['access_token'];
|
||||
$expiresIn = $response['expires_in'] ?? 7200;
|
||||
|
||||
// 缓存token,提前5分钟过期
|
||||
Cache::set($cacheKey, $token, $expiresIn - 300);
|
||||
|
||||
$this->accessToken = $token;
|
||||
return $token;
|
||||
}
|
||||
|
||||
$errcode = $response['errcode'] ?? '?';
|
||||
$errmsg = $response['errmsg'] ?? '未知错误';
|
||||
Log::error('企业微信 gettoken 失败', [
|
||||
'errcode' => $errcode,
|
||||
'errmsg' => $errmsg,
|
||||
'secret_label' => $this->secretLabel,
|
||||
'secret_masked' => $this->maskSecret(),
|
||||
'corp_id' => $this->corpId,
|
||||
]);
|
||||
|
||||
throw new \Exception(sprintf(
|
||||
'获取access_token失败[%s]: %s (secret_label=%s, secret=%s, corp_id=%s)',
|
||||
(string) $errcode,
|
||||
(string) $errmsg,
|
||||
$this->secretLabel,
|
||||
$this->maskSecret(),
|
||||
(string) $this->corpId
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取部门成员列表
|
||||
* @see https://developer.work.weixin.qq.com/document/path/90200
|
||||
*/
|
||||
public function getDepartmentUserList(int $departmentId = 1, bool $fetchChild = true): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/user/list';
|
||||
$params = [
|
||||
'access_token' => $accessToken,
|
||||
'department_id' => $departmentId,
|
||||
'fetch_child' => $fetchChild ? 1 : 0,
|
||||
];
|
||||
|
||||
$response = $this->httpGet($url, $params);
|
||||
|
||||
Log::info('企业微信-获取部门成员列表响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
if (isset($response['userlist'])) {
|
||||
return $response['userlist'];
|
||||
}
|
||||
|
||||
Log::error('获取部门成员列表失败: ' . json_encode($response));
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取成员的客户列表
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92113
|
||||
* @param string $userId 成员ID
|
||||
* @return array|false 返回客户列表数组,权限错误返回false
|
||||
*/
|
||||
public function getExternalContactList(string $userId)
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list';
|
||||
$params = [
|
||||
'access_token' => $accessToken,
|
||||
'userid' => $userId,
|
||||
];
|
||||
|
||||
$response = $this->httpGet($url, $params);
|
||||
|
||||
Log::info('企业微信-获取成员客户列表响应 userId=' . $userId . ': ' . json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// 检查是否是权限错误
|
||||
if (isset($response['errcode']) && $response['errcode'] == 48002) {
|
||||
Log::error('API权限不足(48002): 请在企业微信管理后台开启"客户联系"权限');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($response['external_userid'])) {
|
||||
return $response['external_userid'];
|
||||
}
|
||||
|
||||
// 如果没有客户,返回空数组(不记录错误)
|
||||
if (isset($response['errcode']) && $response['errcode'] == 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Log::error('获取客户列表失败: ' . json_encode($response));
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取指定成员添加的客户详情(分页,单次最多 100 条)
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92994
|
||||
* @return array<string, mixed> 含 errcode、external_contact_list、next_cursor 等
|
||||
*/
|
||||
public function batchGetExternalContactByUser(string $userId, string $cursor = '', int $limit = 100): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/batch/get_by_user?access_token=' . urlencode($accessToken);
|
||||
$body = [
|
||||
'userid_list' => [$userId],
|
||||
'cursor' => $cursor,
|
||||
'limit' => min(100, max(1, $limit)),
|
||||
];
|
||||
|
||||
return $this->httpPostJson($url, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取客户详情
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92114
|
||||
*
|
||||
* @param string $externalUserId
|
||||
* @param array<string, mixed>|null $errorOut 引用出参:当返回空数组时携带 errcode/errmsg/raw,便于上游分流处理
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getExternalContactDetail(string $externalUserId, ?array &$errorOut = null): array
|
||||
{
|
||||
$errorOut = null;
|
||||
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get';
|
||||
$params = [
|
||||
'access_token' => $accessToken,
|
||||
'external_userid' => $externalUserId,
|
||||
];
|
||||
|
||||
$response = $this->httpGet($url, $params);
|
||||
|
||||
if (isset($response['external_contact'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$errcode = isset($response['errcode']) ? (int) $response['errcode'] : null;
|
||||
$errmsg = (string) ($response['errmsg'] ?? '');
|
||||
$errorOut = [
|
||||
'errcode' => $errcode,
|
||||
'errmsg' => $errmsg,
|
||||
'raw' => $response,
|
||||
];
|
||||
|
||||
// 84061: 客户尚未通过验证 / 41068: 不合法的成员/外部联系人 / 60011/60111: 没有权限访问
|
||||
// 这几类是「业务上预期会发生」的,降级为 warning,避免污染 error 日志。
|
||||
// 关键字段直接拼到消息文本里,便于在 ThinkPHP 默认 file 日志格式下 grep 统计 errcode 分布。
|
||||
$line = sprintf(
|
||||
'企业微信-获取客户详情失败 errcode=%s errmsg=%s ext=%s',
|
||||
$errcode === null ? '?' : (string) $errcode,
|
||||
$errmsg !== '' ? $errmsg : '-',
|
||||
$externalUserId
|
||||
);
|
||||
if (in_array($errcode, [84061, 41068, 60011, 60111], true)) {
|
||||
Log::warning($line);
|
||||
} else {
|
||||
Log::error($line);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业群发:员工代发消息给已添加客户 / 客户群
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92135 创建企业群发
|
||||
*
|
||||
* 注意:这里只是「下发提醒」,员工手机端会在企微 App 看到"请发送"的提示,点击后才真正发给客户;
|
||||
* 不点击则消息不会触达。发送结果需通过 getGroupMsgSendResult 查询。
|
||||
*
|
||||
* @param array<string, mixed> $payload 标准字段:
|
||||
* chat_type string 'single' 或 'group',必填
|
||||
* external_userid string[] 客户 external_userid 列表(single 必填,最多 10000;group 不传)
|
||||
* chat_type_external_userid_list ... 预留
|
||||
* sender string 员工 userid(不传则使用创建者自己;推荐显式传)
|
||||
* allow_select bool 为 true 时企微会让员工在企微 App 里勾选客户;默认 false
|
||||
* text ['content' => string]
|
||||
* attachments array[] 单条内可含 image / video / link / miniprogram / file(最多 9 个)
|
||||
*
|
||||
* @return array<string, mixed> 原样返回 errcode / errmsg / msgid / fail_list
|
||||
*/
|
||||
public function addMsgTemplate(array $payload): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/add_msg_template?access_token=' . urlencode($accessToken);
|
||||
|
||||
$response = $this->httpPostJson($url, $payload);
|
||||
$errcode = isset($response['errcode']) ? (int) $response['errcode'] : null;
|
||||
if ($errcode !== 0) {
|
||||
$errmsg = (string) ($response['errmsg'] ?? '');
|
||||
Log::warning(sprintf(
|
||||
'企业微信-企业群发失败 errcode=%s errmsg=%s sender=%s chat_type=%s',
|
||||
$errcode === null ? '?' : (string) $errcode,
|
||||
$errmsg !== '' ? $errmsg : '-',
|
||||
(string) ($payload['sender'] ?? '-'),
|
||||
(string) ($payload['chat_type'] ?? '-')
|
||||
));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询企业群发结果
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/93338
|
||||
* 群发任务创建后,消息会进入发送池,员工手机端需点击确认;此接口返回员工确认/送达/读状态。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getGroupMsgSendResult(string $msgid, string $userid = '', string $cursor = '', int $limit = 500): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get_group_msg_send_result?access_token=' . urlencode($accessToken);
|
||||
|
||||
$body = [
|
||||
'msgid' => $msgid,
|
||||
'limit' => min(1000, max(1, $limit)),
|
||||
'cursor' => $cursor,
|
||||
];
|
||||
if ($userid !== '') {
|
||||
$body['userid'] = $userid;
|
||||
}
|
||||
|
||||
return $this->httpPostJson($url, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传临时素材(企微 media_id,3 天有效)。用于企业群发的 image/video/file 类型 attachment。
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/90253
|
||||
*
|
||||
* @param string $type image|voice|video|file
|
||||
* @param string $path 本地可读文件绝对路径
|
||||
* @param string $filename 上传字段里的 filename,默认取 basename
|
||||
* @return array<string, mixed> 标准响应(包含 media_id)
|
||||
*/
|
||||
public function uploadMedia(string $type, string $path, string $filename = ''): array
|
||||
{
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
Log::error(sprintf('企业微信-上传临时素材失败:文件不存在或不可读 path=%s', $path));
|
||||
|
||||
return ['errcode' => -1, 'errmsg' => 'file not found: ' . $path];
|
||||
}
|
||||
|
||||
$type = in_array($type, ['image', 'voice', 'video', 'file'], true) ? $type : 'file';
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=' . urlencode($accessToken) . '&type=' . $type;
|
||||
|
||||
if ($filename === '') {
|
||||
$filename = basename($path);
|
||||
}
|
||||
$cfile = new \CURLFile($path, mime_content_type($path) ?: 'application/octet-stream', $filename);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, ['media' => $cfile]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
Log::error(sprintf('企业微信-上传临时素材 HTTP=%d type=%s err=%s', $httpCode, $type, $curlErr));
|
||||
|
||||
return ['errcode' => -1, 'errmsg' => 'http ' . $httpCode . ' ' . $curlErr];
|
||||
}
|
||||
|
||||
$result = json_decode((string) $response, true);
|
||||
if (!is_array($result)) {
|
||||
Log::error('企业微信-上传临时素材 响应解析失败:' . (string) $response);
|
||||
|
||||
return ['errcode' => -1, 'errmsg' => 'bad json'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图文/图片的永久图片素材,获取企微 CDN 图片 URL(用于 link/image 附件的 pic_url)
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/90256
|
||||
*/
|
||||
public function uploadImageToCdn(string $path, string $filename = ''): array
|
||||
{
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return ['errcode' => -1, 'errmsg' => 'file not found'];
|
||||
}
|
||||
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/media/uploadimg?access_token=' . urlencode($accessToken);
|
||||
|
||||
if ($filename === '') {
|
||||
$filename = basename($path);
|
||||
}
|
||||
$cfile = new \CURLFile($path, mime_content_type($path) ?: 'image/jpeg', $filename);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, ['media' => $cfile]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
$result = json_decode((string) $response, true);
|
||||
|
||||
return is_array($result) ? $result : ['errcode' => -1, 'errmsg' => 'bad json'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取企业微信部门下所有成员(含字段 userid/name/mobile/department/...),供 admin 选择发送员工
|
||||
*
|
||||
* @see https://developer.work.weixin.qq.com/document/path/90201
|
||||
*/
|
||||
public function getDepartmentUserSimpleList(int $departmentId = 1, bool $fetchChild = true): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/user/simplelist';
|
||||
$params = [
|
||||
'access_token' => $accessToken,
|
||||
'department_id' => $departmentId,
|
||||
'fetch_child' => $fetchChild ? 1 : 0,
|
||||
];
|
||||
|
||||
$response = $this->httpGet($url, $params);
|
||||
|
||||
return isset($response['userlist']) && is_array($response['userlist']) ? $response['userlist'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 检查应用权限(用于诊断)
|
||||
* @return array 返回应用的权限信息
|
||||
*/
|
||||
public function checkAppPermissions(): array
|
||||
{
|
||||
try {
|
||||
$accessToken = $this->getAccessToken();
|
||||
|
||||
// 尝试获取应用详情
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/agent/get';
|
||||
$params = [
|
||||
'access_token' => $accessToken,
|
||||
'agentid' => config('pay.wechat_work.agent_id', 1000002),
|
||||
];
|
||||
|
||||
$response = $this->httpGet($url, $params);
|
||||
|
||||
return [
|
||||
'success' => isset($response['agentid']),
|
||||
'data' => $response,
|
||||
'message' => isset($response['errmsg']) ? $response['errmsg'] : 'OK',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'data' => [],
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes HTTP GET请求
|
||||
*/
|
||||
private function httpGet(string $url, array $params = []): array
|
||||
{
|
||||
if (!empty($params)) {
|
||||
$url .= '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
Log::error('HTTP请求失败: ' . $url . ', HTTP Code: ' . $httpCode);
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
Log::error('JSON解析失败: ' . $response);
|
||||
return [];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON
|
||||
*/
|
||||
private function httpPostJson(string $url, array $body): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json; charset=UTF-8',
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
Log::error('HTTP POST 失败: ' . $url . ', HTTP Code: ' . $httpCode);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = json_decode((string) $response, true);
|
||||
if (!is_array($result)) {
|
||||
Log::error('JSON 解析失败: ' . $response);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user