753 lines
26 KiB
PHP
753 lines
26 KiB
PHP
<?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;
|
|
if ($useBatch) {
|
|
try {
|
|
$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
|
|
);
|
|
}
|
|
} else {
|
|
$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 按成员分页拉取并合并跟进人(列表接口无按时间筛选)
|
|
*
|
|
* @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
|
|
*/
|
|
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),
|
|
'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);
|
|
if (($syncCount % 25) === 0) {
|
|
usleep(8000);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 将企微 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',
|
|
'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))));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @notes 获取统计信息
|
|
*/
|
|
public static function getStats()
|
|
{
|
|
$total = QywxExternalContact::whereNull('delete_time')->count();
|
|
|
|
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
|
// 今日新增:以企微首次添加时间为准;历史行未回填字段(0)时退回 create_time
|
|
$today = QywxExternalContact::whereNull('delete_time')
|
|
->whereRaw(
|
|
'(external_first_add_time >= ? OR (external_first_add_time = 0 AND create_time >= ?))',
|
|
[$todayStart, $todayStart]
|
|
)
|
|
->count();
|
|
|
|
$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,
|
|
'lastSync' => $lastSync,
|
|
'syncStatus' => $settings['sync_status'] ?? 'idle',
|
|
'syncStatusText' => self::getSyncStatusText($settings['sync_status'] ?? 'idle'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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] ?? '未知';
|
|
}
|
|
}
|