Files
zyt/server/app/api/controller/QywxExternalContactCallbackController.php
T
2026-04-21 18:02:27 +08:00

170 lines
7.3 KiB
PHP
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\api\controller;
use app\adminapi\logic\qywx\CustomerLogic;
use EasyWeChat\Kernel\Exceptions\BadRequestException;
use EasyWeChat\Work\Application;
use EasyWeChat\Work\Message;
use think\facade\Log;
/**
* 企业微信「客户联系」事件回调(接收事件服务器)
*
* @see https://developer.work.weixin.qq.com/document/path/92130
*
* ⚠️ 关于"员工↔客户消息内容"接收:
* 企业微信 **不会** 通过本回调推送客户与员工之间真实的聊天消息内容,
* 这里只处理"添加 / 编辑 / 删除客户"等业务事件(change_external_contact 变体)。
* 实时消息接收走「会话内容存档」独立通道:
* - 命令: php think qywx:sync-msg-archive
* - 服务: app\common\service\wechat\QywxMsgArchiveService
* - SDK: app\common\service\wechat\WeComFinanceSdkClient (FFI 调 libWeWorkFinanceSdk_C.so)
* 配置项:pay.wechat_work.msgaudit_*。
*/
class QywxExternalContactCallbackController extends BaseApiController
{
/** 免登录:企微服务器回调 */
public array $notNeedLogin = ['notify'];
public function notify()
{
$corpId = (string) config('pay.wechat_work.corp_id', '');
$customerSecret = (string) config('pay.wechat_work.customer_contact_secret', '');
$payContactSecret = (string) config('pay.wechat_work.external_pay_secret', '');
// 客户联系回调验签需用「接收事件服务器」所属应用的 Secret,优先使用 customer_contact_secret
// 缺省回退到 external_pay_secret 保持向后兼容(同一应用同时具备两类权限的旧部署可继续工作)。
$secret = $customerSecret !== '' ? $customerSecret : $payContactSecret;
$token = (string) config('pay.wechat_work.contact_callback_token', '');
$aesKey = (string) config('pay.wechat_work.contact_callback_aes_key', '');
if ($corpId === '' || $secret === '' || $token === '' || $aesKey === '') {
Log::error('qywx external contact callback: 缺少配置 corp_id / customer_contact_secret(或 external_pay_secret) / contact_callback_token / contact_callback_aes_key');
return response('config error', 503, ['Content-Type' => 'text/plain; charset=utf-8']);
}
try {
$app = new Application([
'corp_id' => $corpId,
'secret' => $secret,
'token' => $token,
'aes_key' => $aesKey,
]);
$server = $app->getServer();
$server->addEventListener('change_external_contact', function (Message $message, \Closure $next) {
try {
$this->handleChangeExternalContact($message);
} catch (\Throwable $e) {
Log::error('qywx external contact callback: ' . $e->getMessage(), [
'exception' => $e,
]);
}
return $next($message);
});
$psr = $server->serve();
$body = $psr->getBody();
$body->rewind();
$content = $body->getContents();
$headers = [];
$contentType = $psr->getHeaderLine('Content-Type');
if ($contentType !== '') {
$headers['Content-Type'] = $contentType;
}
return response($content, 200, $headers);
} catch (BadRequestException $e) {
Log::warning('qywx external contact callback: bad request ' . $e->getMessage());
return response('bad request', 400, ['Content-Type' => 'text/plain; charset=utf-8']);
} catch (\Throwable $e) {
Log::error('qywx external contact callback: serve failed ' . $e->getMessage(), ['exception' => $e]);
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
}
}
/**
* @param Message $message
*/
private function handleChangeExternalContact($message): void
{
$changeType = (string) ($message['ChangeType'] ?? '');
$extId = trim((string) ($message['ExternalUserID'] ?? $message['ExternalUserId'] ?? ''));
$userId = trim((string) ($message['UserID'] ?? $message['UserId'] ?? ''));
$state = (string) ($message['State'] ?? '');
$welcomeCode = (string) ($message['WelcomeCode'] ?? '');
$failReason = (string) ($message['FailReason'] ?? '');
$eventTime = (int) ($message['CreateTime'] ?? 0);
// 事件流水:一进来就落库(幂等),用于"今天进来多少人"等零误差统计;
// 独立于业务 UPSERT,即便后续 DB 逻辑抛错也不影响计数。
CustomerLogic::recordExternalContactEvent([
'change_type' => $changeType,
'user_id' => $userId,
'external_userid' => $extId,
'state' => $state,
'fail_reason' => $failReason,
'welcome_code' => $welcomeCode !== '' ? 1 : 0,
'event_time' => $eventTime,
'raw' => $message,
]);
if ($extId === '') {
Log::info(sprintf('qywx external contact callback: 无 ExternalUserID type=%s user=%s', $changeType, $userId));
return;
}
// 关键字段直接拼到消息里,便于在 ThinkPHP file 日志格式下一眼定位 ChangeType 分布
Log::info(sprintf(
'qywx external contact callback type=%s user=%s ext=%s state=%s welcome=%s fail=%s',
$changeType !== '' ? $changeType : '-',
$userId !== '' ? $userId : '-',
$extId,
$state !== '' ? $state : '-',
$welcomeCode !== '' ? '***' : '-',
$failReason !== '' ? $failReason : '-'
));
if ($changeType === 'del_external_contact') {
CustomerLogic::softDeleteExternalContactRow($extId);
return;
}
if ($changeType === 'add_half_external_contact') {
// 半客户:客户尚未通过验证,/externalcontact/get 通常返回 84061「客户尚未通过」之类,
// 这里只 log 不写库,避免产生 noise;客户通过后会再触发 add_external_contact 事件再走 UPSERT。
Log::info(sprintf('qywx external contact callback: half add,跳过落库 ext=%s user=%s', $extId, $userId));
return;
}
if ($changeType === 'transfer_fail') {
// 转接失败(customer_refused 等):客户并未成功归属新员工,旧跟进人保持不变;
// 此时 /externalcontact/get 多半返回 84061「not external contact」,继续拉详情只会刷无意义 warning。
return;
}
if ($changeType === 'del_follow_user') {
// 某员工不再跟进该客户:只需把本地 follow_users 里对应 userid 移除;
// 若已无跟进人则软删;不再回调 /externalcontact/get(最后一个跟进人被删时会稳定返回 84061)。
if ($userId !== '') {
CustomerLogic::removeFollowUserFromLocal($extId, $userId);
}
return;
}
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
CustomerLogic::upsertSingleExternalContactFromApi($extId);
}
}