Files
zyt/server/app/adminapi/logic/qywx/CustomerLogic.php
T
2026-04-30 09:27:42 +08:00

1528 lines
57 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\adminapi\logic\qywx;
use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\common\model\QywxExternalContact;
use app\common\model\QywxSyncSettings;
use app\common\service\wechat\WechatWorkService;
use think\facade\Cache;
use think\facade\Db;
use think\facade\Log;
/**
* 企业微信客户逻辑层
*/
class CustomerLogic extends BaseLogic
{
private const SYNC_LOCK_KEY = 'qywx_customer_full_sync';
/** @var resource|null 跨进程互斥,避免多路同步同时 UPSERT 同一表导致 1205 */
private static $syncFileLockHandle = null;
/**
* Web 端「立即同步」:拉起 CLI 在后台跑,避免 HTTP/FPM/Nginx 60s 超时;重复点击会提示进行中。
*
* @return array<string, mixed>|false
*/
public static function triggerBackgroundSync()
{
if (Cache::get(self::SYNC_LOCK_KEY)) {
self::$error = '同步任务正在执行中,请稍后再试或刷新页面查看状态';
return false;
}
Cache::set(self::SYNC_LOCK_KEY, time(), 7200);
self::updateSyncStatus('syncing', 0, '', false);
$think = root_path() . 'think';
if (!is_file($think)) {
Cache::delete(self::SYNC_LOCK_KEY);
self::$error = '未找到项目 think 入口,无法启动后台任务';
return false;
}
$phpBin = self::resolvePhpCliBinary();
$logFile = runtime_path() . 'log/qywx_sync.log';
$logDir = dirname($logFile);
if (!is_dir($logDir)) {
@mkdir($logDir, 0755, true);
}
if (PHP_OS_FAMILY === 'Windows') {
$cmd = sprintf(
'start /B cmd /c %s %s qywx:sync-customer --force ^>^> %s 2^>^&1',
escapeshellarg($phpBin),
escapeshellarg($think),
escapeshellarg($logFile)
);
@pclose(@popen($cmd, 'r'));
} else {
$cmd = sprintf(
'%s %s qywx:sync-customer --force >> %s 2>&1 &',
escapeshellarg($phpBin),
escapeshellarg($think),
escapeshellarg($logFile)
);
exec($cmd);
}
return [
'background' => true,
'message' => '已在后台开始同步,请稍候刷新列表或等待状态变为「同步成功」',
];
}
/**
* CLI 路径:FPM 下 PHP_BINARY 可能是 php-fpm,不可用。
*/
private static function resolvePhpCliBinary(): string
{
$fromEnv = (string) env('PHP_CLI_BINARY', '');
if ($fromEnv !== '' && is_executable($fromEnv)) {
return $fromEnv;
}
if (PHP_BINARY !== '' && is_executable(PHP_BINARY) && stripos(PHP_BINARY, 'fpm') === false) {
return PHP_BINARY;
}
return 'php';
}
private static function acquireSyncProcessLock(): bool
{
$dir = runtime_path() . 'lock';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$path = $dir . '/qywx_customer_sync.lock';
$fp = @fopen($path, 'c+');
if ($fp === false) {
Log::error('qywx sync: 无法打开锁文件 ' . $path);
return false;
}
if (!flock($fp, LOCK_EX | LOCK_NB)) {
fclose($fp);
return false;
}
self::$syncFileLockHandle = $fp;
return true;
}
private static function releaseSyncProcessLock(): void
{
if (is_resource(self::$syncFileLockHandle)) {
flock(self::$syncFileLockHandle, LOCK_UN);
fclose(self::$syncFileLockHandle);
self::$syncFileLockHandle = null;
}
}
/**
* @notes 同步企业微信客户(由定时任务或后台 CLI 调用,耗时可数分钟)
*
* @param array{follow_createtime_from?:int,follow_createtime_to?:int,follow_createtime_mode?:string} $options
* follow_createtime_*:与 follow_createtime_mode 联用;mode 默认 first=仅「首次添加」时间在窗口内(今日新客),any=任一条跟进 createtime 在窗口内(旧逻辑,命中多)
*/
public static function syncCustomers(array $options = [])
{
try {
@set_time_limit(0);
} catch (\Throwable) {
}
if (!self::acquireSyncProcessLock()) {
self::$error = '已有同步任务在执行中(请勿同时运行多条 php think qywx:sync-customer 或网页「立即同步」),请等待结束后再试';
return false;
}
try {
try {
Db::execute('SET SESSION innodb_lock_wait_timeout = 120');
} catch (\Throwable $e) {
Log::warning('qywx sync: SET innodb_lock_wait_timeout ' . $e->getMessage());
}
$service = new WechatWorkService();
// 1. 获取部门成员列表(从根部门开始,递归获取所有成员)
$departmentId = config('project.qywx_sync_department_id', 1);
Log::info('开始同步企业微信客户 - 部门ID: ' . $departmentId);
$userList = $service->getDepartmentUserList($departmentId, true);
Log::info('获取到企业成员数量: ' . count($userList));
if (empty($userList)) {
Log::error('未获取到企业成员列表');
self::$error = '未获取到企业成员列表';
return false;
}
$syncCount = 0;
$updateCount = 0;
$newCount = 0;
$skippedCount = 0;
$ctimeFrom = (int) ($options['follow_createtime_from'] ?? 0);
$ctimeTo = (int) ($options['follow_createtime_to'] ?? 0);
$filterByFollowCreatetime = $ctimeFrom > 0 && $ctimeTo >= $ctimeFrom;
$followCtimeMode = (string) ($options['follow_createtime_mode'] ?? 'first');
if ($filterByFollowCreatetime) {
$tz = (string) config('app.default_timezone', 'Asia/Shanghai');
Log::info(sprintf(
'qywx sync: 按跟进时间筛选 mode=%s 窗口=[%s .. %s] tz=%s',
$followCtimeMode,
date('Y-m-d H:i:s', $ctimeFrom),
date('Y-m-d H:i:s', $ctimeTo),
$tz
));
}
// 不使用包层 Db 事务:同步耗时长且含大量 API 调用;模型在已有事务下会嵌套 SAVEPOINT,
// 易导致 MySQL 1305「SAVEPOINT trans2 does not exist」。逐条写入由单次 SQL 保证原子即可。
try {
$useBatch = (bool) config('project.qywx_sync_use_batch_detail', true);
$mergedMap = null;
/** 已走「时间筛选 + 流式 batch」分支且未抛错时,不再 list+get 全量回退 */
$createtimeFilterBatchPathDone = false;
if ($useBatch) {
try {
// 按跟进时间筛选:不构建全量合并表(10 万+ 会爆内存/慢);流式扫 batch,仅对候选 id get+写库
if ($filterByFollowCreatetime) {
$candidates = self::collectCandidateExternalUserIdsForCreatetimeWindow(
$service,
$userList,
$ctimeFrom,
$ctimeTo,
$followCtimeMode
);
Log::info('qywx sync: 时间筛选模式 候选客户数(待 get 校验) ' . count($candidates));
foreach (array_keys($candidates) as $extId) {
$detail = $service->getExternalContactDetail($extId);
if (empty($detail)) {
continue;
}
$externalContact = $detail['external_contact'] ?? [];
$followUsers = $detail['follow_user'] ?? [];
self::upsertOneExternalContactBundle(
$externalContact,
is_array($followUsers) ? $followUsers : [],
true,
$followCtimeMode,
$ctimeFrom,
$ctimeTo,
$syncCount,
$newCount,
$updateCount,
$skippedCount
);
}
$createtimeFilterBatchPathDone = true;
} else {
$mergedMap = self::buildMergedExternalContactsMap($service, $userList);
Log::info('qywx sync: 批量接口合并后客户数 ' . count($mergedMap));
}
} catch (\Throwable $e) {
Log::warning('qywx sync: 批量拉取失败,回退 list+get — ' . $e->getMessage());
$mergedMap = null;
}
}
if ($mergedMap !== null) {
foreach ($mergedMap as $bundle) {
self::upsertOneExternalContactBundle(
$bundle['external_contact'],
$bundle['follow_users'],
$filterByFollowCreatetime,
$followCtimeMode,
$ctimeFrom,
$ctimeTo,
$syncCount,
$newCount,
$updateCount,
$skippedCount
);
}
} elseif (!$createtimeFilterBatchPathDone) {
$processedCustomers = [];
foreach ($userList as $user) {
$userId = $user['userid'] ?? '';
if (empty($userId)) {
continue;
}
Log::info('获取成员客户列表 - userId: ' . $userId . ', name: ' . ($user['name'] ?? ''));
$customerList = $service->getExternalContactList($userId);
if ($customerList === false) {
Log::error('获取客户列表失败 - 可能是API权限未开启(错误码48002)');
throw new \Exception('企业微信API权限不足,请在企业微信管理后台开启"客户联系"权限');
}
Log::info('成员 ' . $userId . ' 的客户数量: ' . count($customerList));
if (empty($customerList)) {
continue;
}
foreach ($customerList as $externalUserId) {
if (isset($processedCustomers[$externalUserId])) {
continue;
}
$processedCustomers[$externalUserId] = true;
$detail = $service->getExternalContactDetail($externalUserId);
if (empty($detail)) {
continue;
}
$externalContact = $detail['external_contact'] ?? [];
$followUsers = $detail['follow_user'] ?? [];
self::upsertOneExternalContactBundle(
$externalContact,
is_array($followUsers) ? $followUsers : [],
$filterByFollowCreatetime,
$followCtimeMode,
$ctimeFrom,
$ctimeTo,
$syncCount,
$newCount,
$updateCount,
$skippedCount
);
}
}
}
// 4. 更新同步设置
self::updateSyncStatus('success', $syncCount);
Log::info('同步完成 - 总数: ' . $syncCount . ', 新增: ' . $newCount . ', 更新: ' . $updateCount);
return [
'sync_count' => $syncCount,
'new_count' => $newCount,
'update_count' => $updateCount,
'skipped_count' => $skippedCount,
];
} catch (\Throwable $e) {
Log::error('同步企业微信客户失败: ' . $e->getMessage());
self::$error = '同步失败: ' . $e->getMessage();
self::updateSyncStatus('failed', 0, $e->getMessage());
return false;
}
} catch (\Throwable $e) {
Log::error('同步企业微信客户异常: ' . $e->getMessage());
self::$error = '同步异常: ' . $e->getMessage();
self::updateSyncStatus('failed', 0, $e->getMessage());
return false;
} finally {
self::releaseSyncProcessLock();
Cache::delete(self::SYNC_LOCK_KEY);
}
}
/**
* 企微 follow_user 单条里的添加时间,统一为 Unix 秒(兼容毫秒、create_time 字段名)
*
* @param array<string, mixed> $fu
*/
public static function normalizeFollowUserCreatetime(array $fu): int
{
$raw = $fu['createtime'] ?? $fu['create_time'] ?? 0;
$ct = (int) $raw;
if ($ct > 1_000_000_000_000) {
$ct = intdiv($ct, 1000);
}
return $ct;
}
/**
* follow_user[].createtime 最小值:企微侧「首次添加为外部联系人」时间(Unix 秒),无则 0
*
* @param array<int, mixed> $followUsers
*/
public static function minFollowCreatetime(array $followUsers): int
{
$min = 0;
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$ct = self::normalizeFollowUserCreatetime($fu);
if ($ct > 0 && ($min === 0 || $ct < $min)) {
$min = $ct;
}
}
return $min;
}
/**
* 「今日」起止 Unix 秒(闭区间结束为次日 0 秒前一秒),使用 app.default_timezone
*
* @return array{0:int,1:int}
*/
public static function todayCreatetimeWindowBounds(): array
{
$tzName = (string) config('app.default_timezone', 'Asia/Shanghai');
$tz = new \DateTimeZone($tzName);
$from = (new \DateTime('today', $tz))->getTimestamp();
$to = (new \DateTime('tomorrow', $tz))->getTimestamp() - 1;
return [$from, $to];
}
/**
* 客户详情 follow_user 中 createtime 是否有任一条落在 [from, to](闭区间,Unix 秒)
*
* @param array<int, mixed> $followUsers
*/
private static function followCreatetimeInRange(array $followUsers, int $from, int $to): bool
{
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$ct = self::normalizeFollowUserCreatetime($fu);
if ($ct >= $from && $ct <= $to) {
return true;
}
}
return false;
}
/**
* 流式扫 batch/get_by_user,按单条 follow_info 做时间窗口启发式,收集可能要落库的 external_userid(去重)。
* 企微无「只拉今日」接口,仍需翻完所有分页;但不构建全量合并表,后续仅对候选调用 get 做精确过滤。
*
* @return array<string, true>
*/
private static function collectCandidateExternalUserIdsForCreatetimeWindow(
WechatWorkService $service,
array $userList,
int $ctimeFrom,
int $ctimeTo,
string $followCtimeMode
): array {
$candidates = [];
foreach ($userList as $user) {
$userId = trim((string) ($user['userid'] ?? ''));
if ($userId === '') {
continue;
}
Log::info('qywx sync(batch 筛选扫描): 成员 ' . $userId . ' ' . ($user['name'] ?? ''));
$cursor = '';
do {
$resp = $service->batchGetExternalContactByUser($userId, $cursor, 100);
$err = (int) ($resp['errcode'] ?? -1);
if ($err !== 0) {
throw new \RuntimeException('errcode=' . $err . ' errmsg=' . (string) ($resp['errmsg'] ?? ''));
}
foreach ($resp['external_contact_list'] ?? [] as $item) {
if (!is_array($item)) {
continue;
}
$fi = $item['follow_info'] ?? [];
if (!is_array($fi)) {
$fi = [];
}
if (!self::batchFollowInfoHintMayMatchCreatetimeWindow($fi, $ctimeFrom, $ctimeTo, $followCtimeMode)) {
continue;
}
$ec = $item['external_contact'] ?? [];
$extId = trim((string) ($ec['external_userid'] ?? ''));
if ($extId !== '') {
$candidates[$extId] = true;
}
}
$cursor = (string) ($resp['next_cursor'] ?? '');
} while ($cursor !== '');
}
return $candidates;
}
/**
* 批量接口单条 follow_info 是否「可能」命中时间策略(用于缩小候选;最终以 get 全量 follow_user 为准)
*
* @param array<string, mixed> $followInfo
*/
private static function batchFollowInfoHintMayMatchCreatetimeWindow(
array $followInfo,
int $ctimeFrom,
int $ctimeTo,
string $followCtimeMode
): bool {
if ($followCtimeMode === 'any') {
return self::followCreatetimeInRange([$followInfo], $ctimeFrom, $ctimeTo);
}
$ct = self::normalizeFollowUserCreatetime($followInfo);
if ($ct > 0 && $ct < $ctimeFrom) {
return false;
}
if ($ct > 0 && $ct > $ctimeTo) {
return false;
}
return true;
}
/**
* 使用 batch/get_by_user 按成员分页拉取并合并跟进人(列表接口无按时间筛选)
*
* @param array<int, array<string, mixed>> $userList
* @return array<string, array{external_contact: array<string, mixed>, follow_users: array<int, array<string, mixed>>}>
*/
private static function buildMergedExternalContactsMap(WechatWorkService $service, array $userList): array
{
$merged = [];
foreach ($userList as $user) {
$userId = trim((string) ($user['userid'] ?? ''));
if ($userId === '') {
continue;
}
Log::info('qywx sync(batch): 成员 ' . $userId . ' ' . ($user['name'] ?? ''));
$cursor = '';
do {
$resp = $service->batchGetExternalContactByUser($userId, $cursor, 100);
$err = (int) ($resp['errcode'] ?? -1);
if ($err !== 0) {
throw new \RuntimeException('errcode=' . $err . ' errmsg=' . (string) ($resp['errmsg'] ?? ''));
}
foreach ($resp['external_contact_list'] ?? [] as $item) {
if (is_array($item)) {
self::mergeBatchExternalItemIntoMap($merged, $item);
}
}
$cursor = (string) ($resp['next_cursor'] ?? '');
} while ($cursor !== '');
}
return $merged;
}
/**
* @param array<string, array{external_contact: array<string, mixed>, follow_users: array<int, array<string, mixed>>}> $merged
* @param array<string, mixed> $item
*/
private static function mergeBatchExternalItemIntoMap(array &$merged, array $item): void
{
$ec = $item['external_contact'] ?? [];
if (!is_array($ec)) {
$ec = [];
}
$fi = $item['follow_info'] ?? [];
if (!is_array($fi)) {
$fi = [];
}
$extId = trim((string) ($ec['external_userid'] ?? ''));
if ($extId === '') {
return;
}
if (!isset($merged[$extId])) {
$merged[$extId] = [
'external_contact' => $ec,
'follow_users' => [],
];
} else {
$merged[$extId]['external_contact'] = self::mergeExternalContactShallow(
$merged[$extId]['external_contact'],
$ec
);
}
$uid = trim((string) ($fi['userid'] ?? ''));
if ($uid === '') {
return;
}
foreach ($merged[$extId]['follow_users'] as $ex) {
if (is_array($ex) && (($ex['userid'] ?? '') === $uid)) {
return;
}
}
$merged[$extId]['follow_users'][] = $fi;
}
/**
* @param array<string, mixed> $base
* @param array<string, mixed> $in
* @return array<string, mixed>
*/
private static function mergeExternalContactShallow(array $base, array $in): array
{
foreach ($in as $k => $v) {
if ($v === null || $v === '') {
continue;
}
if (!array_key_exists($k, $base) || $base[$k] === '' || $base[$k] === null) {
$base[$k] = $v;
}
}
return $base;
}
/**
* @param array<string, mixed> $externalContact
* @param array<int, array<string, mixed>> $followUsers
*/
/**
* 客户联系事件回调:按 external_userid 拉取详情并 UPSERT(不走全量同步文件锁)。
*
* @see https://developer.work.weixin.qq.com/document/path/92130
*/
public static function upsertSingleExternalContactFromApi(string $externalUserId): void
{
$externalUserId = trim($externalUserId);
if ($externalUserId === '') {
return;
}
$service = new WechatWorkService();
$detail = [];
$lastError = null;
// 接口偶发抖动:最多重试 1 次(共 2 次请求),间隔 1.2s。仅对「非预期错误 / 空响应」重试,
// 业务预期错误(如 84061 客户未通过、60011/60111 无权限、41068 非法 ID)不重试,避免无谓压力。
$maxAttempts = 2;
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$err = null;
$detail = $service->getExternalContactDetail($externalUserId, $err);
if (!empty($detail['external_contact']) && is_array($detail['external_contact'])) {
break;
}
$lastError = $err;
$errcode = is_array($err) ? ($err['errcode'] ?? null) : null;
$shouldRetry = $attempt < $maxAttempts
&& !in_array($errcode, [84061, 41068, 60011, 60111], true);
if (!$shouldRetry) {
break;
}
usleep(1_200_000);
}
if (empty($detail['external_contact']) || !is_array($detail['external_contact'])) {
$errcode = is_array($lastError) ? ($lastError['errcode'] ?? null) : null;
$errmsg = is_array($lastError) ? (string) ($lastError['errmsg'] ?? '') : '';
Log::warning(sprintf(
'qywx external contact callback: 拉取详情为空,跳过 UPSERT errcode=%s errmsg=%s ext=%s',
$errcode === null ? '?' : (string) $errcode,
$errmsg !== '' ? $errmsg : '-',
$externalUserId
));
return;
}
$followUsers = $detail['follow_user'] ?? [];
if (!is_array($followUsers)) {
$followUsers = [];
}
$syncCount = 0;
$newCount = 0;
$updateCount = 0;
$skippedCount = 0;
self::upsertOneExternalContactBundle(
$detail['external_contact'],
$followUsers,
false,
'first',
0,
0,
$syncCount,
$newCount,
$updateCount,
$skippedCount
);
}
/**
* 事件流水(零误差进入计数)
*
* 每次 change_external_contact 回调都调用一次,`INSERT IGNORE` 幂等:
* - 企微对非 2xx 响应最多重试 3 次,同一 (change_type,user_id,external_userid,event_time) 只会计一行;
* - 与业务 UPSERT 解耦,即便后续 DB 逻辑抛错也不影响"今日进入数"统计。
*
* 入参 $data
* - change_type string
* - user_id string
* - external_userid string
* - state string
* - fail_reason string
* - welcome_code int 0/1(只存是否带欢迎码,不落原 code,避免泄漏)
* - event_time int 企微 CreateTime(秒)
* - raw mixed 原始消息(用于留痕;非标量会 json_encode
*
* @param array<string, mixed> $data
*/
public static function recordExternalContactEvent(array $data): void
{
$changeType = (string) ($data['change_type'] ?? '');
if ($changeType === '') {
// 没有 ChangeType 的事件流水没有价值,直接丢弃
return;
}
$eventTime = (int) ($data['event_time'] ?? 0);
if ($eventTime <= 0) {
$eventTime = time();
}
$raw = $data['raw'] ?? null;
if ($raw !== null && !is_string($raw)) {
try {
$raw = json_encode($raw, JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR);
} catch (\Throwable) {
$raw = null;
}
}
$row = [
'change_type' => $changeType,
'user_id' => (string) ($data['user_id'] ?? ''),
'external_userid' => (string) ($data['external_userid'] ?? ''),
'state' => mb_substr((string) ($data['state'] ?? ''), 0, 128),
'fail_reason' => mb_substr((string) ($data['fail_reason'] ?? ''), 0, 64),
'welcome_code' => !empty($data['welcome_code']) ? 1 : 0,
'event_time' => $eventTime,
'create_time' => time(),
'raw' => $raw,
];
try {
// INSERT IGNORE:命中唯一键 (change_type,user_id,external_userid,event_time) 时静默跳过。
// ThinkPHP 未提供统一的 ignore helper,这里直接用原生 SQL(与 SyncImChatArchive 的做法一致)。
$cols = array_keys($row);
$table = config('database.connections.mysql.prefix') . 'qywx_external_contact_event';
$sql = 'INSERT IGNORE INTO `' . $table . '` (`' . implode('`,`', $cols) . '`) VALUES ('
. implode(',', array_fill(0, count($cols), '?')) . ')';
Db::execute($sql, array_values($row));
} catch (\Throwable $e) {
// 事件流水只用于统计,失败只记日志不阻塞主回调
Log::warning('qywx external contact event insert failed: ' . $e->getMessage());
}
}
/**
* 客户联系「删除企业客户」等事件:本地软删除一行。
*/
public static function softDeleteExternalContactRow(string $externalUserId): void
{
$externalUserId = trim($externalUserId);
if ($externalUserId === '') {
return;
}
$now = time();
Db::name('qywx_external_contact')->where('external_userid', $externalUserId)->update([
'delete_time' => $now,
'update_time' => $now,
]);
// 客户已删 → 关系表硬删(无 delete_time 列;统计场景不需要保留)
Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->delete();
}
/**
* `del_follow_user` 事件:某员工不再跟进该客户。
* 只更新本地 `follow_users` JSON:移除匹配的 userid;若已无跟进人则软删该行。
* 不再回调 /externalcontact/get,避免 84061「not external contact」刷 warning。
*/
public static function removeFollowUserFromLocal(string $externalUserId, string $userId): void
{
$externalUserId = trim($externalUserId);
$userId = trim($userId);
if ($externalUserId === '' || $userId === '') {
return;
}
$row = Db::name('qywx_external_contact')
->where('external_userid', $externalUserId)
->find();
if (!$row) {
return;
}
$followUsers = json_decode((string) ($row['follow_users'] ?? '[]'), true);
$followUsers = is_array($followUsers) ? $followUsers : [];
$kept = [];
$removed = false;
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$uid = trim((string) ($fu['userid'] ?? ''));
if ($uid === $userId) {
$removed = true;
continue;
}
$kept[] = $fu;
}
if (!$removed) {
return;
}
$now = time();
if ($kept === []) {
Db::name('qywx_external_contact')
->where('id', (int) $row['id'])
->update([
'follow_users' => json_encode([], JSON_UNESCAPED_UNICODE),
'tags' => '[]',
'delete_time' => $now,
'update_time' => $now,
]);
// 整客户已无人跟进 → 关系表清空
Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->delete();
return;
}
$minCreate = self::minFollowCreatetime($kept);
$update = [
'follow_users' => json_encode($kept, JSON_UNESCAPED_UNICODE),
'tags' => self::extractFollowUserTags($kept),
'update_time' => $now,
];
if ($minCreate > 0) {
// 移除最早那条跟进人后,首次添加时间可能后移;重新刷新字段以与 JSON 保持一致
$update['external_first_add_time'] = $minCreate;
}
Db::name('qywx_external_contact')
->where('id', (int) $row['id'])
->update($update);
// 关系表按剩余 kept follow_users 同步(自动清掉离开员工那行 + 保留其他员工的标签)
self::syncContactTagsRelation($externalUserId, $kept);
}
private static function upsertOneExternalContactBundle(
array $externalContact,
array $followUsers,
bool $filterByFollowCreatetime,
string $followCtimeMode,
int $ctimeFrom,
int $ctimeTo,
int &$syncCount,
int &$newCount,
int &$updateCount,
int &$skippedCount
): void {
if ($filterByFollowCreatetime) {
if ($followCtimeMode === 'any') {
$pass = self::followCreatetimeInRange($followUsers, $ctimeFrom, $ctimeTo);
} else {
$minCt = self::minFollowCreatetime($followUsers);
$pass = $minCt >= $ctimeFrom && $minCt <= $ctimeTo;
}
if (!$pass) {
$skippedCount++;
return;
}
}
$now = time();
$row = [
'external_userid' => $externalContact['external_userid'] ?? '',
'name' => $externalContact['name'] ?? '',
'avatar' => $externalContact['avatar'] ?? '',
'type' => $externalContact['type'] ?? 1,
'gender' => $externalContact['gender'] ?? 0,
'unionid' => $externalContact['unionid'] ?? '',
'position' => $externalContact['position'] ?? '',
'corp_name' => $externalContact['corp_name'] ?? '',
'corp_full_name' => $externalContact['corp_full_name'] ?? '',
'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE),
'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE),
'tags' => self::extractFollowUserTags($followUsers),
'follow_admin_ids' => self::resolveFollowAdminIds($followUsers),
'external_first_add_time' => self::minFollowCreatetime($followUsers),
'create_time' => $now,
'update_time' => $now,
'delete_time' => null,
];
self::upsertExternalContactRow($row, $syncCount, $newCount, $updateCount);
// 同步「客户↔员工↔标签」关系表(用于检索/统计;列表筛选/聚合不必再解析 follow_users JSON
self::syncContactTagsRelation((string) $row['external_userid'], $followUsers);
if (($syncCount % 25) === 0) {
usleep(8000);
}
}
/**
* 同步「客户↔员工↔标签」关系表(zyt_qywx_external_contact_tag)。
*
* 行粒度:(external_userid, follow_user_id, tag_id) 三元组——同一标签由不同员工打则各占一行。
* 用于检索/统计/聚合:按标签筛客户、统计某标签客户数、多标签 AND/OR、按分组漏斗等。
*
* 写入策略(保留 create_time,仅在改名/分组变化时刷 tag_name/group_name/type):
* 1. 计算目标三元组集合
* 2. 删除"该 external_userid 下"不在新集合里的旧关系(含跟进人离开 / 标签移除)
* 3. 对每个新三元组 INSERT ... ON DUPLICATE KEY UPDATE 刷新冗余字段
*
* @param array<int, mixed> $followUsers /externalcontact/get 返回的 follow_user[]
* @internal 仅供 UPSERT / 回填命令复用
*/
public static function syncContactTagsRelation(string $externalUserId, array $followUsers): void
{
$externalUserId = trim($externalUserId);
if ($externalUserId === '') {
return;
}
// 1. 拍平为 (follow_user_id, tag_id) → 行 的字典;保持 follow_user_id 维度区分
$rows = [];
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$followUserId = trim((string) ($fu['userid'] ?? ''));
$tags = $fu['tags'] ?? [];
if (!is_array($tags)) {
continue;
}
foreach ($tags as $t) {
if (!is_array($t)) {
continue;
}
$tagId = trim((string) ($t['tag_id'] ?? ''));
if ($tagId === '') {
continue;
}
$key = $followUserId . '|' . $tagId;
$rows[$key] = [
'follow_user_id' => mb_substr($followUserId, 0, 64),
'tag_id' => mb_substr($tagId, 0, 64),
'tag_name' => mb_substr((string) ($t['tag_name'] ?? ''), 0, 128),
'group_name' => mb_substr((string) ($t['group_name'] ?? ''), 0, 128),
'type' => isset($t['type']) ? (int) $t['type'] : 1,
];
}
}
$now = time();
// 2. 删除该客户下不在目标集合里的旧关系
if ($rows === []) {
Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->delete();
return;
}
// 用 (follow_user_id || tag_id) 拼字符串过滤;记录少时 PHP 拉出来比 SQL CONCAT 更高效
$existing = Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->field(['id', 'follow_user_id', 'tag_id'])
->select()
->toArray();
$deleteIds = [];
foreach ($existing as $ex) {
$key = (string) ($ex['follow_user_id'] ?? '') . '|' . (string) ($ex['tag_id'] ?? '');
if (!isset($rows[$key])) {
$deleteIds[] = (int) $ex['id'];
}
}
if ($deleteIds !== []) {
Db::name('qywx_external_contact_tag')
->whereIn('id', $deleteIds)
->delete();
}
// 3. UPSERT 新关系(命中唯一键时只刷冗余字段 + update_timecreate_time 保持原值)
foreach ($rows as $r) {
try {
Db::name('qywx_external_contact_tag')
->duplicate(['tag_name', 'group_name', 'type', 'update_time'])
->insert([
'external_userid' => $externalUserId,
'follow_user_id' => $r['follow_user_id'],
'tag_id' => $r['tag_id'],
'tag_name' => $r['tag_name'],
'group_name' => $r['group_name'],
'type' => $r['type'],
'create_time' => $now,
'update_time' => $now,
]);
} catch (\Throwable $e) {
Log::warning('qywx external contact tag upsert failed: ' . $e->getMessage(), [
'ext' => $externalUserId,
'follow_user_id' => $r['follow_user_id'],
'tag_id' => $r['tag_id'],
]);
}
}
}
/**
* 把所有 follow_user[].tags 合并去重(按 tag_id),返回 JSON 字符串。
*
* 一个客户可能同时被多个员工跟进,每个员工各自打的 tag 在各自的 follow_user.tags 里;
* 这里按 tag_id 唯一去重,保留 tag_name / group_name / type 完整结构,便于后台直接渲染/筛选,
* 不必再解析 follow_users 大 JSON。
*
* 来源结构示例:
* "tags":[{"group_name":"自媒体1","tag_name":"自媒体1","type":1,"tag_id":"etRr2..."}]
*
* @internal 仅供 UPSERT / 回填命令复用,不属于业务对外 API
* @param array<int, mixed> $followUsers
*/
public static function extractFollowUserTags(array $followUsers): string
{
$bag = [];
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$tags = $fu['tags'] ?? [];
if (!is_array($tags)) {
continue;
}
foreach ($tags as $t) {
if (!is_array($t)) {
continue;
}
$tagId = trim((string) ($t['tag_id'] ?? ''));
if ($tagId === '') {
continue;
}
if (isset($bag[$tagId])) {
continue;
}
$bag[$tagId] = [
'group_name' => (string) ($t['group_name'] ?? ''),
'tag_name' => (string) ($t['tag_name'] ?? ''),
'type' => isset($t['type']) ? (int) $t['type'] : 1,
'tag_id' => $tagId,
];
}
}
return json_encode(array_values($bag), JSON_UNESCAPED_UNICODE);
}
/**
* 将企微 follow_user[].userid 与后台 admin.work_wechat_userid 对齐,得到 zyt_admin.id 列表 JSON。
*
* @param array<int, mixed> $followUsers
*/
private static function resolveFollowAdminIds(array $followUsers): string
{
$wxUserids = [];
foreach ($followUsers as $fu) {
if (!is_array($fu)) {
continue;
}
$uid = trim((string) ($fu['userid'] ?? ''));
if ($uid !== '') {
$wxUserids[] = $uid;
}
}
$wxUserids = array_values(array_unique($wxUserids));
if ($wxUserids === []) {
return '[]';
}
$idByWx = Admin::whereIn('work_wechat_userid', $wxUserids)->column('id', 'work_wechat_userid');
$adminIds = [];
foreach ($wxUserids as $wx) {
if (!empty($idByWx[$wx])) {
$adminIds[] = (int) $idByWx[$wx];
}
}
return json_encode(array_values(array_unique($adminIds)), JSON_UNESCAPED_UNICODE);
}
/**
* 是否 MySQL 锁等待超时 / 死锁(可短重试)
*/
private static function isMysqlLockTimeoutOrDeadlock(\Throwable $e): bool
{
$m = $e->getMessage();
return str_contains($m, '1205') || str_contains($m, '1213') || str_contains($m, 'Deadlock');
}
/**
* INSERT ... ON DUPLICATE KEY UPDATE + 锁冲突重试
*
* @param array<string, mixed> $row
*/
private static function upsertExternalContactRow(array $row, int &$syncCount, int &$newCount, int &$updateCount): void
{
$maxAttempts = 12;
$attempt = 0;
while (true) {
$attempt++;
try {
$affected = Db::name('qywx_external_contact')->duplicate([
'name',
'avatar',
'type',
'gender',
'unionid',
'position',
'corp_name',
'corp_full_name',
'external_profile',
'follow_users',
'tags',
'follow_admin_ids',
'external_first_add_time',
'update_time',
'delete_time',
])->insert($row);
$syncCount++;
// PDO MySQL:新插入 1;更新 2;值完全相同 0
if ($affected === 1) {
$newCount++;
} elseif ($affected === 2) {
$updateCount++;
} elseif ($affected === 0) {
$updateCount++;
}
return;
} catch (\Throwable $e) {
if ($attempt >= $maxAttempts || !self::isMysqlLockTimeoutOrDeadlock($e)) {
throw $e;
}
usleep(min(800_000, 40_000 * (2 ** ($attempt - 1))));
}
}
}
/**
* 标签维度统计:用于「按标签筛客户」下拉选项 + 「按标签看分布」面板
*
* 数据源:zyt_qywx_external_contact_tag(关系表)+ zyt_qywx_external_contact(剔除已软删客户)
* 输出按 group_name 分组、组内按客户数倒序,每条带 tag_id / tag_name / customer_count
*
* @return array{
* total_tags: int,
* total_relations: int,
* total_tagged_customers: int,
* groups: array<int, array{group_name: string, customer_count: int, tags: array<int, array{tag_id: string, tag_name: string, customer_count: int}>}>
* }
*/
public static function getTagStats(): array
{
// 关系表里 external_userid 可能指向已被软删的客户;这里 INNER JOIN 主表过滤未删除的
$rows = Db::name('qywx_external_contact_tag')
->alias('ect')
->join('qywx_external_contact ec', 'ec.external_userid = ect.external_userid', 'INNER')
->whereNull('ec.delete_time')
->field([
'ect.tag_id',
'ect.tag_name',
'ect.group_name',
'COUNT(DISTINCT ect.external_userid) AS customer_count',
])
->group('ect.tag_id, ect.tag_name, ect.group_name')
->order('customer_count', 'desc')
->select()
->toArray();
$groupMap = [];
$customersUnion = [];
foreach ($rows as $r) {
$g = (string) ($r['group_name'] ?? '');
if (!isset($groupMap[$g])) {
$groupMap[$g] = [
'group_name' => $g,
'customer_count' => 0,
'tags' => [],
];
}
$cnt = (int) ($r['customer_count'] ?? 0);
$groupMap[$g]['tags'][] = [
'tag_id' => (string) ($r['tag_id'] ?? ''),
'tag_name' => (string) ($r['tag_name'] ?? ''),
'customer_count' => $cnt,
];
}
// 组内已经按 customer_count desc 排好序了(外面 SQL 排序结果);
// 组本身按"组内最大 customer_count"倒序排(最热门标签所在组优先)
foreach ($groupMap as &$g) {
$g['customer_count'] = $g['tags'][0]['customer_count'] ?? 0;
}
unset($g);
$groups = array_values($groupMap);
usort($groups, static function ($a, $b) {
return $b['customer_count'] <=> $a['customer_count'];
});
// 全表 distinct 客户数(带 tag 的客户)
$taggedCustomers = (int) Db::name('qywx_external_contact_tag')
->alias('ect')
->join('qywx_external_contact ec', 'ec.external_userid = ect.external_userid', 'INNER')
->whereNull('ec.delete_time')
->group('ect.external_userid')
->count();
$totalRelations = (int) Db::name('qywx_external_contact_tag')
->alias('ect')
->join('qywx_external_contact ec', 'ec.external_userid = ect.external_userid', 'INNER')
->whereNull('ec.delete_time')
->count();
return [
'total_tags' => count($rows),
'total_relations' => $totalRelations,
'total_tagged_customers' => $taggedCustomers,
'groups' => $groups,
];
}
/**
* @notes 获取统计信息
*/
public static function getStats()
{
$total = QywxExternalContact::whereNull('delete_time')->count();
$todayStart = strtotime(date('Y-m-d 00:00:00'));
// 今日进入数 = 今日收到的 add_external_contact 事件条数。
// - 事件表 INSERT IGNORE 幂等,企微自动重试不会重复计数;
// - 与业务表 UPSERT/软删解耦,客户即使很快被删、或 /externalcontact/get 同步失败,都不影响计数;
// - 取 event_time(企微 CreateTime)而不是 create_time,跨日分钟内推送过来也归到事件真实日期。
$today = (int) Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('event_time', '>=', $todayStart)
->count();
/** 今日新增客户中,跟进人条数合计(同一 userid 多客户或多条均累计,不去重) */
$todayFollowStaff = 0;
$followJsonList = QywxExternalContact::whereNull('delete_time')
->whereRaw(
'(external_first_add_time >= ? OR (external_first_add_time = 0 AND create_time >= ?))',
[$todayStart, $todayStart]
)
->column('follow_users');
foreach ($followJsonList as $json) {
$raw = json_decode((string) $json, true);
if (!is_array($raw)) {
continue;
}
foreach ($raw as $fu) {
if (!is_array($fu)) {
continue;
}
$wx = trim((string) ($fu['userid'] ?? ''));
if ($wx !== '') {
$todayFollowStaff++;
}
}
}
$settings = self::getSyncSettings();
$lastSyncTime = $settings['last_sync_time'] ?? 0;
$lastSync = $lastSyncTime > 0 ? date('Y-m-d H:i:s', $lastSyncTime) : '';
return [
'total' => $total,
'today' => $today,
'today_follow_staff' => $todayFollowStaff,
'lastSync' => $lastSync,
'syncStatus' => $settings['sync_status'] ?? 'idle',
'syncStatusText' => self::getSyncStatusText($settings['sync_status'] ?? 'idle'),
];
}
/**
* 今日"进入"分布概览(直观卡片用)
*
* 数据源:qywx_external_contact_eventadd_external_contact)——回调一到就幂等写入,
* 与 qywx_external_contact 的软删/UPSERT 解耦,保证"进入数"零误差。
*
* 返回:
* - total int 今日进入总数(= stats.today
* - recent_time int 今日最近一条 add 事件时间戳(0 表示今天还没进人)
* - hourly int[24] 按 0-23 点分布(本地时区)
* - by_state array 按 state 分组 Top 5[{state, count}]
*
* @return array<string, mixed>
*/
public static function getTodayArrivalStats(): array
{
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = $todayStart + 86400;
$total = (int) Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('event_time', '>=', $todayStart)
->where('event_time', '<', $todayEnd)
->count();
$recent = (int) Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('event_time', '>=', $todayStart)
->where('event_time', '<', $todayEnd)
->max('event_time');
$hourly = array_fill(0, 24, 0);
if ($total > 0) {
// 用 FROM_UNIXTIME 按本地时区分桶;php.ini / mysql time_zone 有差异时,可改 DATE_FORMAT
$rows = Db::name('qywx_external_contact_event')
->fieldRaw('HOUR(FROM_UNIXTIME(event_time)) AS h, COUNT(*) AS c')
->where('change_type', 'add_external_contact')
->where('event_time', '>=', $todayStart)
->where('event_time', '<', $todayEnd)
->group('h')
->select()
->toArray();
foreach ($rows as $r) {
$h = (int) ($r['h'] ?? 0);
if ($h >= 0 && $h <= 23) {
$hourly[$h] = (int) ($r['c'] ?? 0);
}
}
}
$byState = [];
if ($total > 0) {
$stateRows = Db::name('qywx_external_contact_event')
->field(['state', 'COUNT(*) AS c'])
->where('change_type', 'add_external_contact')
->where('event_time', '>=', $todayStart)
->where('event_time', '<', $todayEnd)
->group('state')
->orderRaw('c DESC')
->limit(5)
->select()
->toArray();
foreach ($stateRows as $r) {
$byState[] = [
'state' => (string) ($r['state'] ?? ''),
'count' => (int) ($r['c'] ?? 0),
];
}
}
return [
'total' => $total,
'recent_time' => $recent,
'hourly' => array_values($hourly),
'by_state' => $byState,
];
}
/**
* 今日"进入明细"流水(分页,按时间倒序)
*
* 左联客户表补客户名/头像,左联 admin 补员工名;客户已删 / 同步未回不影响流水本身。
*
* @return array{total:int, page_no:int, page_size:int, lists:array<int,array<string,mixed>>}
*/
public static function getTodayArrivalList(int $pageNo, int $pageSize): array
{
$pageNo = max(1, $pageNo);
$pageSize = min(100, max(1, $pageSize));
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = $todayStart + 86400;
$base = Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('event_time', '>=', $todayStart)
->where('event_time', '<', $todayEnd);
$total = (int) (clone $base)->count();
$rows = (clone $base)
->orderRaw('event_time DESC, id DESC')
->page($pageNo, $pageSize)
->select()
->toArray();
if ($rows === []) {
return ['total' => $total, 'page_no' => $pageNo, 'page_size' => $pageSize, 'lists' => []];
}
$extIds = array_values(array_unique(array_filter(array_map(static fn($r) => (string) ($r['external_userid'] ?? ''), $rows))));
$userIds = array_values(array_unique(array_filter(array_map(static fn($r) => (string) ($r['user_id'] ?? ''), $rows))));
$customerMap = [];
if ($extIds !== []) {
$customerRows = Db::name('qywx_external_contact')
->field(['external_userid', 'name', 'avatar'])
->whereIn('external_userid', $extIds)
->select()
->toArray();
foreach ($customerRows as $c) {
$customerMap[(string) $c['external_userid']] = $c;
}
}
$adminMap = [];
if ($userIds !== []) {
$adminMap = Admin::whereIn('work_wechat_userid', $userIds)->column('name', 'work_wechat_userid');
}
$lists = [];
foreach ($rows as $r) {
$ext = (string) ($r['external_userid'] ?? '');
$uid = (string) ($r['user_id'] ?? '');
$lists[] = [
'id' => (int) $r['id'],
'event_time' => (int) $r['event_time'],
'user_id' => $uid,
'admin_name' => (string) ($adminMap[$uid] ?? ''),
'external_userid' => $ext,
'customer_name' => (string) ($customerMap[$ext]['name'] ?? ''),
'customer_avatar' => (string) ($customerMap[$ext]['avatar'] ?? ''),
'state' => (string) ($r['state'] ?? ''),
'welcome_code' => (int) ($r['welcome_code'] ?? 0),
];
}
return [
'total' => $total,
'page_no' => $pageNo,
'page_size' => $pageSize,
'lists' => $lists,
];
}
/**
* @notes 获取同步设置
*/
public static function getSyncSettings()
{
$setting = QywxSyncSettings::where('setting_key', 'customer_sync')->find();
if (!$setting) {
return [
'auto_sync' => false,
'interval' => 3600,
'last_sync_time' => 0,
'sync_status' => 'idle',
];
}
$value = json_decode($setting->setting_value, true);
return $value ?: [
'auto_sync' => false,
'interval' => 3600,
'last_sync_time' => 0,
'sync_status' => 'idle',
];
}
/**
* @notes 保存同步设置
*/
public static function saveSyncSettings(array $params)
{
try {
$currentSettings = self::getSyncSettings();
$newSettings = array_merge($currentSettings, [
'auto_sync' => (bool)($params['auto_sync'] ?? false),
'interval' => (int)($params['interval'] ?? 3600),
]);
$setting = QywxSyncSettings::where('setting_key', 'customer_sync')->find();
if ($setting) {
$setting->setting_value = json_encode($newSettings, JSON_UNESCAPED_UNICODE);
$setting->update_time = time();
$setting->save();
} else {
QywxSyncSettings::create([
'setting_key' => 'customer_sync',
'setting_value' => json_encode($newSettings, JSON_UNESCAPED_UNICODE),
'create_time' => time(),
'update_time' => time(),
]);
}
return true;
} catch (\Throwable $e) {
self::$error = '保存失败: ' . $e->getMessage();
return false;
}
}
/**
* @param bool $bumpLastSyncTime 为 false 时仅改状态(用于 syncing),避免「最后同步」在跑任务期间被刷新
*/
private static function updateSyncStatus(string $status, int $syncCount = 0, string $error = '', bool $bumpLastSyncTime = true)
{
try {
$settings = self::getSyncSettings();
$settings['sync_status'] = $status;
if ($bumpLastSyncTime) {
$settings['last_sync_time'] = time();
$settings['last_sync_count'] = $syncCount;
}
if ($error) {
$settings['last_error'] = $error;
}
$payload = json_encode($settings, JSON_UNESCAPED_UNICODE);
$now = time();
// 直连 UPDATE,避免模型层隐式事务/事件拉长锁时间
$n = Db::name('qywx_sync_settings')
->where('setting_key', 'customer_sync')
->update([
'setting_value' => $payload,
'update_time' => $now,
]);
if ($n === 0) {
Db::name('qywx_sync_settings')->insert([
'setting_key' => 'customer_sync',
'setting_value' => $payload,
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (\Throwable $e) {
Log::error('更新同步状态失败: ' . $e->getMessage());
}
}
/**
* @notes 获取同步状态文本
*/
private static function getSyncStatusText(string $status): string
{
$map = [
'idle' => '未同步',
'syncing' => '同步中',
'success' => '同步成功',
'failed' => '同步失败',
];
return $map[$status] ?? '未知';
}
}