233 lines
11 KiB
PHP
Executable File
233 lines
11 KiB
PHP
Executable File
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\api\controller;
|
||
|
||
use app\adminapi\logic\qywx\CustomerLogic;
|
||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
|
||
use app\common\service\qywx\QywxPromotionRangeSyncService;
|
||
use app\common\service\qywx\QywxPromotionAutomationService;
|
||
use app\common\service\qywx\QywxPromotionEnqueueException;
|
||
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
|
||
*
|
||
* ⚠️ 关于"员工↔客户消息内容"接收:
|
||
* 企业微信 **不会** 通过本回调推送客户与员工之间真实的聊天消息内容,
|
||
* 这里只处理客户关系事件,以及 customer_acquisition 回调中的累计收消息次数;不保存消息正文。
|
||
* 实时消息接收走「会话内容存档」独立通道:
|
||
* - 命令: 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', '');
|
||
$acquisitionSecret = (string) config('qywx_customer_acquisition.secret', '');
|
||
$payContactSecret = (string) config('pay.wechat_work.external_pay_secret', '');
|
||
// 客户联系回调验签需用「接收事件服务器」所属应用的 Secret,优先使用 customer_contact_secret,
|
||
// 缺省回退到 external_pay_secret 保持向后兼容(同一应用同时具备两类权限的旧部署可继续工作)。
|
||
$secret = $customerSecret !== ''
|
||
? $customerSecret
|
||
: ($acquisitionSecret !== '' ? $acquisitionSecret : $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(或获客助手应用 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 (QywxPromotionEnqueueException $e) {
|
||
// 未持久化不能假应答成功:外层返回500,让企微重新投递。
|
||
throw $e;
|
||
} catch (\Throwable $e) {
|
||
Log::error('qywx external contact callback: ' . $e->getMessage(), [
|
||
'exception' => $e,
|
||
]);
|
||
}
|
||
|
||
return $next($message);
|
||
});
|
||
|
||
$server->addEventListener('customer_acquisition', function (Message $message, \Closure $next) {
|
||
try {
|
||
(new QywxCustomerAcquisitionCustomerService())->handleCallback($message->toArray());
|
||
} catch (\Throwable $e) {
|
||
// 服务已将失败事件与 next_retry 落库,定时命令会在 ChatKey 30 分钟内继续重试。
|
||
Log::error('qywx customer acquisition 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 (QywxPromotionEnqueueException) {
|
||
// 异常调用栈可能携带原始事件参数;这里只记固定信息,不记录WelcomeCode。
|
||
Log::error('qywx external contact callback: promotion event persistence failed');
|
||
|
||
return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||
} 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);
|
||
|
||
// 只接管已保存新配置且可核验方案/成员的推广事件。
|
||
// 回调内立即尝试欢迎语和正式客户标签,常驻/分钟任务继续承担重试及其余慢动作。
|
||
// 同时处理带欢迎码的半客户,避免原来的半客户早返回吞掉20秒欢迎语窗口。
|
||
$event = $message instanceof Message ? $message->toArray() : (array) $message;
|
||
$queued = (new QywxPromotionAutomationService())->enqueueVerifiedEvent($event, true);
|
||
$auditEvent = $event;
|
||
unset($auditEvent['WelcomeCode']);
|
||
|
||
// 事件流水:一进来就落库(幂等),用于"今天进来多少人"等零误差统计;
|
||
// 独立于业务 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' => $auditEvent,
|
||
]);
|
||
|
||
if ($queued) {
|
||
// 即时通道已尝试欢迎语/标签;分钟补偿完成备注、成员记账、范围与客户资料同步。
|
||
return;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
if ($changeType === 'add_external_contact' && $state !== '' && $userId !== '') {
|
||
// customer_channel=zyt_pool:{id} 会原样进入 State;以实际 UserID 幂等记账并更新可用成员范围。
|
||
try {
|
||
$dispatch = QywxPromotionMemberSchedulerService::recordFromState(
|
||
$state,
|
||
$userId,
|
||
$extId,
|
||
$eventTime,
|
||
'external_contact'
|
||
);
|
||
if (($dispatch['status'] ?? '') === 'counted' && (int) ($dispatch['pool_id'] ?? 0) > 0) {
|
||
try {
|
||
// 回调后立即应用上限变化;分钟任务仍负责网络异常、并发版本变化等情况的兜底重试。
|
||
(new QywxPromotionRangeSyncService())->syncPool((int) $dispatch['pool_id']);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('qywx promotion immediate range sync failed: ' . $e->getMessage());
|
||
}
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// 客户资料同步与成员调度互不阻塞;调度异常保留日志,获客会话回调仍可补偿。
|
||
Log::error('qywx promotion callback dispatch failed: ' . $e->getMessage(), ['exception' => $e]);
|
||
}
|
||
}
|
||
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
|
||
CustomerLogic::upsertSingleExternalContactFromApi($extId);
|
||
}
|
||
}
|